.NET

31 jul, 2018

Autenticação em APIs ASP.Net Core com JWT – Refresh Token

Publicidade

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!