Quando trabalhamos com autenticação baseada em tokens JWT, temos que considerar que ele tem um tempo de expiração que irá invalidá-lo em determinado momento. Em alguns cenários, pode ser interessante obter um novo token de acesso sem precisar forçar o usuário a fazer um novo login informando seu usuário e senha. Isso é muito comum quando trabalhamos com aplicativos mobile.
Para esse artigo, usei o mesmo exemplo de código de um artigo anterior, onde mostro como implementar uma API de Autenticação simples com JWT. Você pode conferir em meu GitHub.
O que é Refresh Token?
Imagine que o cliente faça login no seu aplicativo informando usuário e senha. Sua API devolve um token JWT que seu app deverá armazenar internamente. Sabemos que em todas as requisições seguintes, o app deverá enviar o token no header Authorization para a API que irá consumir.
O cliente coloca o app em background e só volta a usá-lo após algumas horas. Muito provavelmente o token de acesso criado anteriormente já expirou e, ao tentar fazer qualquer chamada na API o app, irá receber um HTTP Status Code 401 – Unauthorized. Você tem duas opções?
- a) Redirecionar o cliente para a tela de login e forçá-lo a fazer um novo acesso
- b) Pedir para que a API de autenticação forneça um novo token JWT válido de forma transparente para o cliente.
Caso seu app não necessite que o cliente faça um novo login a cada tempo de expiração, como é o caso de aplicativos bancários, por exemplo, você pode optar por gerar um novo token de forma transparente para o cliente, fazendo uso do que chamamos de Refresh Token.
Para isso, além do token de acesso principal, você deve gerar um token secundário com tempo de expiração maior. Por exemplo, se o access_token expira em 8h, seu refresh_token irá expirar em 16 horas (não precisar ser necessariamente esse tempo – você deve definir um tempo adequado).
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.
Como implementar?
No projeto de exemplo, você irá encontrar uma classe chamada JwtService, que é responsável por gerar um token JWT. O que eu fiz foi implementar a criação do Refresh Token da seguinte forma.
public class JwtService : IJwtService { private readonly JwtSettings _settings; public JwtService(JwtSettings settings) { _settings = settings; } public JsonWebToken CreateJsonWebToken(User user) { // ... código omitido return new JsonWebToken { AccessToken = accessToken, RefreshToken = CreateRefreshToken(user.Email), ExpiresIn = (long)TimeSpan.FromMinutes(_settings.ValidForMinutes).TotalSeconds }; } private RefreshToken CreateRefreshToken(string username) { var refreshToken = new RefreshToken { Username = username, ExpirationDate = _settings.RefreshTokenExpiration }; string token; var randomNumber = new byte[32]; using (var rng = RandomNumberGenerator.Create()) { rng.GetBytes(randomNumber); token = Convert.ToBase64String(randomNumber); } refreshToken.Token = token.Replace("+", string.Empty) .Replace("=", string.Empty) .Replace("/", string.Empty); return refreshToken; } // ... código omitido }
O método CreateRefreshToken irá gerar um valor aleatório de 32 bytes, que é convertido para string onde, em seguida, são removidos alguns caracteres indesejados – nada de mais!
Esse método deverá ser usado no momento da criação do token JWT, no método CreateJsonWebToken.
Como vocês já sabem, eu gosto muito de usar o MediatR em meus projetos/ inclusive já escrevi um artigo sobre ele.
Tenho um request chamado Authenticate, que irá disparar o handler AuthenticateHandler.
A classe Authenticate não tem nada demais. Ela contém apenas as propriedades necessárias para o input dos dados pelo aplicativo, como e-mail, password, refresh token e grant type, além de algumas validações.
public class Authenticate : Request<Response> { public string Email { get; } public string Password { get; } public string GrantType { get; } public string RefreshToken { get; } public Authenticate(string grantType, string email, string password, string refreshToken) { Validate(grantType, email, password, refreshToken); GrantType = grantType; Email = email; Password = password; RefreshToken = refreshToken; } private void Validate(string grantType, string email, string password, string refreshToken) { AddNotifications(new Contract() .Requires() .IsNotNullOrEmpty(grantType, nameof(grantType), "O tipo de autenticação não pode ficar vazio")); if (!string.IsNullOrEmpty(grantType)) { if (grantType.Equals("password")) { AddNotifications(new Contract() .Requires() .IsEmail(email, nameof(email), "E-mail inválido") .IsNotNullOrEmpty(password, nameof(password), "A senha não pode ficar vazia")); } else if (grantType.Equals("refresh_token")) { AddNotifications(new Contract() .Requires() .IsNotNullOrEmpty(refreshToken, nameof(refreshToken), "O refresh token não pode ficar vazio")); } else { AddNotification(new Notification(nameof(grantType), "Tipo de autenticação inválido")); } } } }
public class AuthenticateHandler : IRequestHandler<Authenticate, Response> { private readonly IJwtService _jwtService; private readonly IUserRepository _userRepository; private readonly IRefreshTokenRepository _refreshTokenRepository; private Response _response; public AuthenticateHandler( IJwtService jwtService, IUserRepository userRepository, IRefreshTokenRepository refreshTokenRepository) { _jwtService = jwtService; _userRepository = userRepository; _refreshTokenRepository = refreshTokenRepository; } public async Task<Response> Handle(Authenticate request, CancellationToken cancellationToken) { _response = new Response(); User user = null; if (request.GrantType.Equals("password")) { user = await HandleUserAuthentication(request); } else if (request.GrantType.Equals("refresh_token")) { user = await HandleRefreshToken(request); } if (_response.HasMessages || user == null) { return _response; } await HandleJwt(user); return _response; } private async Task HandleJwt(User user) { var jwt = _jwtService.CreateJsonWebToken(user); await _refreshTokenRepository.Save(jwt.RefreshToken); _response.AddValue(new { access_token = jwt.AccessToken, refresh_token = jwt.RefreshToken.Token, token_type = jwt.TokenType, expires_in = jwt.ExpiresIn }); } private async Task<User> HandleUserAuthentication(Authenticate request) { var encodedPassword = new Password(request.Password).Encoded; var user = await _userRepository.Authenticate(request.Email, encodedPassword); if (user == null) { _response.AddNotification(new Notification("user", "Usuário ou senha inválidos")); } return user; } private async Task<User> HandleRefreshToken(Authenticate request) { var token = await _refreshTokenRepository.Get(request.RefreshToken); if (token == null) { _response.AddNotification(new Notification(nameof(request.RefreshToken), "Refresh Token inválido")); } else if (token.ExpirationDate < DateTime.Now) { _response.AddNotification(new Notification(nameof(request.RefreshToken), "Refresh Token expirado")); } if (_response.HasMessages) { return null; } return await _userRepository.Get(token.Username); } }
Na classe AuthenticateHandler, que é responsável por responder à uma solicitação de login, no método Handle, verifico qual tipo de autenticação está sendo feita através da propriedade GrantType do request do MediatR e então executo o método apropriado.
- GrantType == password → método comum de autenticação, onde verificamos as credenciais de acesso fornecidas pelo usuário, como usuário e senha, por exemplo;
- GrantType == refresh_token → método de autenticação baseado no refresh token que geramos anteriormente.
Isso é necessário pois normalmente existe apenas um único endpoint de autenticação na API que irá gerar um token de acesso, e é através do GrantType que diferenciamos a forma de autenticação. Existem implementações diferentes disso, onde temos dois endpoints de autenticação; um para validar as credenciais inseridas pelo usuário (login e senha) e outro exclusivo para o Refresh Token. Em nosso exemplo usaremos um único endpoint.
Além disso, veja que ao gerar um token JWT no método HandleJwt, o Refresh Token gerado é salvo no banco de dados, sempre sobrescrevendo o anterior de forma que só exista um único hash de refresh por usuário. Isso é importante, pois quando o app tentar fazer a atualização do token de acesso, devemos verificar se o refresh token informado é válido.
Nesse caso, no método HandleRefreshToken, faço uma busca no banco de dados pelo hash informado, verifico se ele realmente existe e se está expirado. Lembre-se de que ele também tem um tempo de expiração. Além dessas duas validações, você pode implementar um mecanismo de revogação do refresh token para inutilizá-lo imediatamente, e então pode fazer essa validação de revogação nesse momento.
Caso o refresh token seja válido, basta consultar os dados do usuário no banco e gerar um novo JWT, bem como um novo refresh token.
A controller na API é bem simples, ela apenas recebe o request HTTP de login, o mecanismo de Model Binding do ASP.Net se encarrega de fazer o bind apropriado para o objeto Authenticate, e então ele é enviado para o MediatR, que irá disparar o handler AuthenticateHandler que vimos anteriormente.
[Route("api/[controller]")] public class AccountsController : Controller { private readonly IMediator _mediator; public AccountsController(IMediator mediator) { _mediator = mediator; } [HttpPost, AllowAnonymous, Route("login")] public async Task<IActionResult> Authenticate([FromBody] Authenticate command) { var response = await _mediator.Send(command); if (response.HasMessages) { return BadRequest(response.Messages); } return Ok(response.Value); } // ... código omitido }
Além disso, existe a configuração do tempo de expiração do Refresh Token, que fica no arquivo appsettings.json da nossa API e é representado pela chave RefreshTokenValidForMinutes. Nesse exemplo, o access_token (JWT) tem um tempo de expiração de 60 minutos, já o refresh_token irá expirar em 120 minutos. Como eu disse anteriormente, o tempo de expiração do refresh token deve ser maior que o tempo de expiração do JWT. Essa configuração é materializada na classe JwtSettings.
{ "JwtSettings": { "Issuer": "DemoJwtApi", "Audience": "demo-jwt-api", "SigningKey": "kzfSPDKwdx5KnyxtBTlwNW_IoqrpbaGRwaFNdqxQyv-WVIqeLKOGJVLmh4lRd4wUPmolq6CM7Bs4r1NRbAoZQZQui80YbqMGuymdw5NSlnMvoMHNdF2niiydKV5X2esajAZk6t1pu1Jf05TNIxQBO1aI8xnk4ttVIPXRDG47wKlTPwnvqpVX3lh5nwrG_A4fUj7KOslfysPbusORDePIQlnnCqkzURl3qanQzjku02kWxujqpujl3I1VpJ0zKc2ReeyVNoeKNG3WYi2eO8sYsDw8XtbkcY5mJW7dHeXSMYvzrFIWDbbxorb5LP0FtFbsgOfh8IYT4qzSL4BmUV17ag", "ValidForMinutes": 60, "RefreshTokenValidForMinutes": 120 } }
Testando
Ao executar a API, uma tela do Swagger será aberta, mas nesse caso, vou usar o Postman para fazer os testes.
Com a API em execução e com um usuário criado, devo fazer um novo login informando um usuário e senha. Note que informei o GrantType com o valor password. Caso as credenciais informadas estejam corretas, um token JWT é devolvido.
Conforme disse anteriormente, o aplicativo deve armazenar os valores de access_token e também do refresh_token internamente para posterior uso.
Em posse do token JWT, o app deverá enviá-lo através do header Authorization em todas as demais requisições nas APIs que são utilizadas. Quando o token estiver expirado, a API irá retornar um HTTP Status Code 401 — Unauthorized. Nesse momento, o app pode pedir um novo token de acesso fazendo uso do hash de refresh token armazenado, desde que ele também não esteja expirado, sendo que nesse caso, o usuário deveria fazer um novo login.
Para pedir um novo token, basta chamar novamente o endpoint de login. Entretanto, agora devemos informar o GrantType com o valor refresh_token e o hash armazenado anteriormente. Caso tudo esteja certo, um novo token JWT é devolvido, juntamente com um novo hash de refresh token.
Caso o hash de refresh não seja válido, a API de autenticação poderá exibir as devidas mensagens de erro durante a atualização do token.
- Refresh Token não encontrado
- Refresh Token expirado
E com isso temos um mecanismo simples de atualização de tokens de acesso.
Conclusão
Conforme vimos, a atualização de tokens de acesso é um recurso muito poderoso e pode nos poupar algumas dores de cabeça, afinal, ninguém quer um cliente insatisfeito por ter que fazer login repetidamente em seu aplicativo. Lembrando que essa é apenas uma das várias formas de implementar o Refresh Token em sua API de Autenticação. Se você procurar, irá encontrar outros modelos de implementação.
Soluções como o Identity Server já possuem esse e outros recursos de segurança nativos e mais completos, sendo essa demo apenas um modelo simples para implementar autenticação em seu app.
Espero que tenham gostado, e se ficou alguma dúvida ou caso tenham críticas e sugestões, não deixem de entrar em contato.
Abraços!