ASP.NET Core 2.0 + JWT: implementando Refresh Tokens
Continuando o seu artigo sobre ASP.NET Core 2.0 + JWT, o autor mostra como implementar Refresh Tokens.
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:








