.NET

7 mai, 2018

ASP.NET Core 2.0 + JWT: implementando Refresh Tokens

Publicidade

Já abordei em artigos anteriores a utilização de JSON Web Tokens (JWT) em APIs REST construídas com o ASP.NET Core 2.0. Empregando tokens criptografados, essa técnica é suportada pelas principais plataformas de desenvolvimento da atualidade e oferece um meio seguro para o acesso a recursos de uso restrito.

O fato de um token possuir um tempo de validade pré-determinado pode ser uma limitação em algumas situações. Expirado tal token, um novo deverá ser gerado, a fim de permitir que se continue a interação com os recursos providos por uma API.

Soluções para contornar este comportamento existem e envolvem o uso de Refresh Tokens:

  • Um valor adicional (um segundo token) será gerado, a fim de permitir a solicitação posterior de um novo token de acesso atualizado;
  • Esse procedimento dispensa a necessidade de repetir todo o processo de autenticação que aconteceu durante a obtenção do token inicial, contribuindo assim para uma maior performance.

Discuti em detalhes o uso de JWT com ASP.NET Core no seguinte artigo:

Este novo artigo complementa a solução apresentada no artigo mencionado, através das modificações descritas a seguir.

E aproveito este espaço para deixar aqui um convite:

Hoje, às 22h00 — horário de Brasília — teremos mais um hangout no Canal .NET. Neste Live Demo demonstrarei como implementar testes de aplicações Web utilizando o .NET Core e o Selenium WebDriver. A transmissão acontecerá via YouTube em um link a ser disponibilizado em breve.

Para efetuar a sua inscrição acesse a página do evento no Facebook ou então o Meetup. A transmissão acontecerá via YouTube, em um link a ser divulgado em breve.

Implementando o uso de Refresh Tokens

Foram adicionadas ao projeto APIAlturas, as classes:

AccessCredentials, que pode conter as credenciais de acesso (usuario + chave) ou o Refresh Token. Será a propriedade e GrantType que identificará o conjunto de dados que se está manipulando (valores password ou refresh_token);
RefreshTokenData, representação com os dados necessários para a inclusão de um Refresh Token, um meio de armazenamento temporário (para isso, será utilizado o Redis).
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;

namespace APIAlturas
{
    public class AccessCredentials
    {
        public string UserID { get; set; }
        public string AccessKey { get; set; }
        public string RefreshToken { get; set; }
        public string GrantType { get; set; }
    }

    public class User
    {
        public string UserID { get; set; }
        public string AccessKey { get; set; }
    }

    public class RefreshTokenData
    {
        public string RefreshToken { get; set; }
        public string UserID { get; set; }
    }

    public class TokenConfigurations
    {
        public string Audience { get; set; }
        public string Issuer { get; set; }
        public int Seconds { get; set; }
        public int FinalExpiration { get; set; }
    }

    public class SigningConfigurations
    {
        public SecurityKey Key { get; }
        public SigningCredentials SigningCredentials { get; }

        public SigningConfigurations()
        {
            using (var provider = new RSACryptoServiceProvider(2048))
            {
                Key = new RsaSecurityKey(provider.ExportParameters(true));
            }

            SigningCredentials = new SigningCredentials(
                Key, SecurityAlgorithms.RsaSha256Signature);
        }
    }
}

Na classe Startup serão incluídas instruções no método ConfigureServices para ativar o uso de cache via Redis. O armazenamento temporário de um Refresh Token procura restringir sua utilização por um tempo pré-estabelecido, evitando potenciais falhas na segurança da aplicação:

using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication.JwtBearer;

namespace APIAlturas
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            // Ativando o uso de cache via Redis
            services.AddDistributedRedisCache(options =>
            {
                options.Configuration =
                    Configuration.GetConnectionString("ConexaoRedis");
                options.InstanceName = "APIAlturas";
            });

            services.AddTransient<UsersDAO>();

            var signingConfigurations = new SigningConfigurations();
            services.AddSingleton(signingConfigurations);

            var tokenConfigurations = new TokenConfigurations();
            new ConfigureFromConfigurationOptions<TokenConfigurations>(
                Configuration.GetSection("TokenConfigurations"))
                    .Configure(tokenConfigurations);
            services.AddSingleton(tokenConfigurations);


            services.AddAuthentication(authOptions =>
            {
                authOptions.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                authOptions.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            }).AddJwtBearer(bearerOptions =>
            {
                var paramsValidation = bearerOptions.TokenValidationParameters;
                paramsValidation.IssuerSigningKey = signingConfigurations.Key;
                paramsValidation.ValidAudience = tokenConfigurations.Audience;
                paramsValidation.ValidIssuer = tokenConfigurations.Issuer;

                // Valida a assinatura de um token recebido
                paramsValidation.ValidateIssuerSigningKey = true;

                // Verifica se um token recebido ainda é válido
                paramsValidation.ValidateLifetime = true;

                // Tempo de tolerância para a expiração de um token (utilizado
                // caso haja problemas de sincronismo de horário entre diferentes
                // computadores envolvidos no processo de comunicação)
                paramsValidation.ClockSkew = TimeSpan.Zero;
            });

            // Ativa o uso do token como forma de autorizar o acesso
            // a recursos deste projeto
            services.AddAuthorization(auth =>
            {
                auth.AddPolicy("Bearer", new AuthorizationPolicyBuilder()
                    .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme‌​)
                    .RequireAuthenticatedUser().Build());
            });

            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseMvc();
        }
    }
}

O tipo LoginController também passou por alterações:

  • Os dados enviados no corpo de uma requisição correspondem às informações da classe AccessCredentials;
  • Uma instância de IDistributedCache será recebida via injeção de dependência (foi marcada com o atributo [FromServices]), a fim de possibilitar a interação com o Redis e o armazenamento temporário de Refresh Tokens (o tempo em que um token deste tipo permanecerá armazenado está indicado na propriedade FinalExpiration de TokenConfigurations);
  • A autenticação convencional (usuário + chave) ou via Refresh Token será verificada através da propriedade GrantType definida em AccessCredentials. Se tratando de um Refresh Token, o mesmo será removido (um novo token deste tipo será gerado, assim como outro para acesso à API de alturas via Bearer Authentication);
  • O código referente à geração do token para acesso à API de alturas (e que contém informações adicionais, como o Refresh Token) foi transferido para o método GenerateToken.
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Principal;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Caching.Distributed;
using Newtonsoft.Json;

namespace APIAlturas.Controllers
{
    [Route("api/[controller]")]
    public class LoginController : Controller
    {
        [AllowAnonymous]
        [HttpPost]
        public object Post(
            [FromBody]AccessCredentials credenciais,
            [FromServices]UsersDAO usersDAO,
            [FromServices]SigningConfigurations signingConfigurations,
            [FromServices]TokenConfigurations tokenConfigurations,
            [FromServices]IDistributedCache cache)
        {
            bool credenciaisValidas = false;
            if (credenciais != null && !String.IsNullOrWhiteSpace(credenciais.UserID))
            {
                if (credenciais.GrantType == "password")
                {
                    var usuarioBase = usersDAO.Find(credenciais.UserID);
                    credenciaisValidas = (usuarioBase != null &&
                        credenciais.UserID == usuarioBase.UserID &&
                        credenciais.AccessKey == usuarioBase.AccessKey);
                }
                else if (credenciais.GrantType == "refresh_token")
                {
                    if (!String.IsNullOrWhiteSpace(credenciais.RefreshToken))
                    {
                        RefreshTokenData refreshTokenBase = null;

                        string strTokenArmazenado =
                            cache.GetString(credenciais.RefreshToken);
                        if (!String.IsNullOrWhiteSpace(strTokenArmazenado))
                        {
                            refreshTokenBase = JsonConvert
                                .DeserializeObject<RefreshTokenData>(strTokenArmazenado);
                        }

                        credenciaisValidas = (refreshTokenBase != null &&
                            credenciais.UserID == refreshTokenBase.UserID &&
                            credenciais.RefreshToken == refreshTokenBase.RefreshToken);

                        // Elimina o token de refresh já que um novo será gerado
                        if (credenciaisValidas)
                            cache.Remove(credenciais.RefreshToken);
                    }

                }
            }

            if (credenciaisValidas)
            {
                return GenerateToken(
                    credenciais.UserID, signingConfigurations,
                    tokenConfigurations, cache);
            }
            else
            {
                return new
                {
                    authenticated = false,
                    message = "Falha ao autenticar"
                };
            }
        }

        private object GenerateToken(string userID,
            SigningConfigurations signingConfigurations,
            TokenConfigurations tokenConfigurations,
            IDistributedCache cache)
        {
            ClaimsIdentity identity = new ClaimsIdentity(
                new GenericIdentity(userID, "Login"),
                new[] {
                        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
                        new Claim(JwtRegisteredClaimNames.UniqueName, userID)
                }
            );

            DateTime dataCriacao = DateTime.Now;
            DateTime dataExpiracao = dataCriacao +
                TimeSpan.FromSeconds(tokenConfigurations.Seconds);
            
            // Calcula o tempo máximo de validade do refresh token
            // (o mesmo será invalidado automaticamente pelo Redis)
            TimeSpan finalExpiration =
                TimeSpan.FromSeconds(tokenConfigurations.FinalExpiration);

            var handler = new JwtSecurityTokenHandler();
            var securityToken = handler.CreateToken(new SecurityTokenDescriptor
            {
                Issuer = tokenConfigurations.Issuer,
                Audience = tokenConfigurations.Audience,
                SigningCredentials = signingConfigurations.SigningCredentials,
                Subject = identity,
                NotBefore = dataCriacao,
                Expires = dataExpiracao
            });
            var token = handler.WriteToken(securityToken);

            var resultado = new
            {
                authenticated = true,
                created = dataCriacao.ToString("yyyy-MM-dd HH:mm:ss"),
                expiration = dataExpiracao.ToString("yyyy-MM-dd HH:mm:ss"),
                accessToken = token,
                refreshToken = Guid.NewGuid().ToString().Replace("-", String.Empty),
                message = "OK"
            };

            // Armazena o refresh token em cache através do Redis 
            var refreshTokenData = new RefreshTokenData();
            refreshTokenData.RefreshToken = resultado.refreshToken;
            refreshTokenData.UserID = userID;

            DistributedCacheEntryOptions opcoesCache =
                new DistributedCacheEntryOptions();
            opcoesCache.SetAbsoluteExpiration(finalExpiration);
            cache.SetString(resultado.refreshToken,
                JsonConvert.SerializeObject(refreshTokenData),
                opcoesCache);

            return resultado;
        }
    }
}

As fontes do projeto descrito neste artigo já estão disponíveis no GitHub:

Testes

Um primeiro teste via Postman trouxe o Access Token (utilizado no header de requisições para autenticação) e o Refresh Token:

Nesse momento será possível observar que o Refresh Token já se encontra armazenado no Redis:

Na próxima imagem está o resultado retornado pela API de conversão de alturas, com a requisição em questão tendo sido configurada para o uso do Access Token via Bearer Authentication:

Um novo token de acesso pode também ser obtido através do uso do Refresh Token (desde que esse último esteja dentro do período de validade):

E para concluir este artigo, não deixe de acompanhar também o artigo a seguir, em que venho agrupando todos os conteúdos que tenho produzido sobre o uso de JWT em ASP.NET Core:

Referências