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: