.NET

4 dez, 2017

ASP.NET Core 2.0: autenticação em APIs utilizando JWT (JSON Web Tokens)

Publicidade

Controlar o acesso a APIs REST é uma necessidade comum aos mais variados tipos de projetos. Esta preocupação se torna ainda maior em APIs expostas na Internet, nas quais a utilização indevida de um recurso pode resultar em graves problemas (como perda de informações confidenciais, prejuízos financeiros e ações legais, por exemplo).

O uso do padrão aberto conhecido como JSON Web Tokens (JWT) corresponde a uma das práticas mais difundidas visando o acesso seguro à APIs. Esta abordagem se vale de tokens criptografados para assim liberar a utilização de recursos de uma API, sendo que tal técnica recebeu o nome de Bearer Authentication.

Um token baseado na especificação JWT é formado por 3 partes. Cada uma delas separadas por ponto:

xxxxx.yyyyy.zzzzz

As diferentes partes envolvidas são:

  1. Header: cabeçalho contendo o tipo do token (JWT) e o mecanismo de criptografia utilizado (HMAC ou RSA);
  2. Payload: conjunto de claims associadas ao usuário referenciado pelo token;
  3. Signature: assinatura empregada na validação do token, dependendo para isto de uma chave controlada pela aplicação que faz uso deste mecanismo de autenticação.

O valor a seguir representa um padrão de token gerado com base no padrão RSA:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6WyJ1c3VhcmlvMDEiLCJ1c3VhcmlvMDEiXSwianRpIjoiZDIyZWVmNjA5Y2ExNGM3Mjk3MTQxNzM5MTMzOTJjN2YiLCJuYmYiOjE1MDgwMTE2NjUsImV4cCI6MTUwODAxMTc4NSwiaWF0IjoxNTA4MDExNjY1LCJpc3MiOiJFeGVtcGxvSXNzdWVyIiwiYXVkIjoiRXhlbXBsb0F1ZGllbmNlIn0.VOakhtmpNo25FKl6UK1cvg39pjYdDZlxNl9713KTgICRFWKIZeLEggD3luizc_hDK9eTL5RcaY0lNSlx4oo-2-cwSHxK0xGwC5GNq1ewQ2lkIyqQGUkKkvkH91N82VQdKX3m3zX6uFEaV04pOqaPqNU69RDBTIkV9erIzvYi-sBXtts-qzAQF0ljis34p36iXDEr9HfL5yMW-JmtLI5Tt2jROMI3sT-RRPMc-8xkPctLWudV3h7Q6gXu1E3EUiSUXDsIiGKRWSaTLWijIjfmN49tSOmBmGddalDhoxGudCTUb5hfliWrR1ckLoJwe1H4d8lZx0j_0n5EktS-97T04w

No próprio site da especificação JWT existe um debbuger que permite analisar este conteúdo:

E como funcionaria exatamente a autenticação via token? Este processo envolve as seguintes etapas:

  1. Uma requisição HTTP do tipo POST é enviada a um sistema Web, podendo conter em seu corpo informações como usuário e/ou chaves/senhas de acesso
  2. Caso as credenciais sejam válidas, um token baseado na especificação JWT é gerado
  3. O token é então devolvido à aplicação que encaminhou a requisição. Importante destacar que essa estrutura (token) possui uma validade, expirando após um período de tempo estabelecido previamente. Todo este processo é stateless, ou seja, ocorre sem o armazenamento de estados na memória do servidor. A opção por um token dispensa também múltiplas pesquisas a um repositório com credenciais de acesso (um banco de dados, por exemplo), contribuindo assim para uma maior performance no uso de APIs REST
  4. Requisições a recursos de APIs REST da aplicação Web conterão no header, o token obtido anteriormente
  5. O token informado na seção Authorization do header é então analisado
  6. Caso seja válido, o acesso ao recurso é então liberado. Uma resposta é devolvida, então, como resultado de um processamento na API REST

Na imagem a seguir está uma representação esquemática deste processo:

A nova plataforma Web da Microsoft também suporta JWT como mecanismo de autenticação em APIs REST. Nas próximas seções será abordada esta prática, com isso acontecendo através de um exemplo implementado com base no ASP.NET Core 2.0.

Alguns detalhes sobre a aplicação de exemplo

A API apresentada neste artigo fará a conversão de alturas em pés (unidade comumente utilizada na aviação) para o equivalente em metros. Já descrevi, inclusive, a implementação desta funcionalidade em outro artigo:

ASP.NET Core: APIs REST na nuvem com Docker e Azure Web App

Além do ASP.NET Core 2.0, serão utilizados também o SQL Server e o Dapper. Um banco de dados será criado, contendo credenciais de usuários (ID + chave de acesso) utilizadas por um mecanismo customizado de autenticação, detalhado mais adiante.

Para este artigo, tomei ainda como base, um exemplo em ASP.NET Core 1.0 disponibilizado pela própria Microsoft:

How to achieve a bearer token authentication and authorization in ASP.NET Core

Criando a base de dados

Uma base chamada ExemploJWT será criada no SQL Server para utilização na API de testes. Fará parte deste banco de dados a tabela dbo.Users, na qual constarão IDs de usuários e suas respectivas chaves de acesso:

USE ExemploJWT
GO

CREATE TABLE dbo.Users(
	UserID varchar(20) NOT NULL,
	AccessKey varchar(32) NOT NULL,
	CONSTRAINT PK_Clientes PRIMARY KEY (UserID)
)
GO


INSERT INTO dbo.Users
           (UserID
           ,AccessKey)
     VALUES
           ('usuario01'
           ,'94be650011cf412ca906fc335f615cdc')

INSERT INTO dbo.Users
           (UserID
           ,AccessKey)
     VALUES
           ('usuario02'
           ,'531fd5b19d58438da0fd9afface43b3c')

Implementando a aplicação de exemplo

Será criado um projeto do tipo ASP.NET Core Web Application chamado APIAlturas:

Selecionar para isto o template Web API, além das opções .NET Core e ASP.NET Core 2.0:

No arquivo appsettings.json serão incluídas a string de conexão para acesso à base ExemploJWT, além de configurações para a geração do token (Audience, Issuer e tempo de duração em segundos):

{
  "ConnectionStrings": {
    "ExemploJWT": "Data Source=.\\MSSQLSERVER2016;Initial Catalog=ExemploJWT;Integrated Security=SSPI;"
  },
  "TokenConfigurations": {
    "Audience": "ExemploAudience",
    "Issuer": "ExemploIssuer",
    "Seconds": 120
  },
  "Logging": {
    "IncludeScopes": false,
    "Debug": {
      "LogLevel": {
        "Default": "Warning"
      }
    },
    "Console": {
      "LogLevel": {
        "Default": "Warning"
      }
    }
  }
}

A próxima listagem traz a implementação das seguintes estruturas:

  • User: tipo empregado na manipulação de credenciais de usuários;
  • TokenConfigurations: classe que conterá configurações (Audience, Issuer – emissor, Seconds – tempo de validade em segundos) empregadas na geração de tokens. Estas definições serão obtidas a partir do arquivo appsettings.json.
namespace APIAlturas
{
    public class User
    {
        public string UserID { get; set; }
        public string AccessKey { get; set; }
    }

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

Já a classe UsersDAO acessará a base ExemploJWT e fará uso do Dapper, retornando através do método Find, instâncias do tipo User que conterão o ID de um usuário e sua respectiva chave de acesso:

using System.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Dapper;

namespace APIAlturas
{
    public class UsersDAO
    {
        private IConfiguration _configuration;

        public UsersDAO(IConfiguration configuration)
        {
            _configuration = configuration;
        }

        public User Find(string userID)
        {
            using (SqlConnection conexao = new SqlConnection(
                _configuration.GetConnectionString("ExemploJWT")))
            {
                return conexao.QueryFirstOrDefault<User>(
                    "SELECT UserID, AccessKey " +
                    "FROM dbo.Users " +
                    "WHERE UserID = @UserID", new { UserID = userID });
            }
        }
    }
}

No tipo SigningConfigurations foram definidos:

  • A propriedade Key, à qual será vinculada uma instância da classe SecurityKey (namespace Microsoft.IdentityModel.Tokens) armazenando a chave de criptografia utilizada na criação de tokens;
  • A propriedade SigningCredentials, que receberá um objeto baseado em uma classe também chamada SigningCredentials (namespace Microsoft.IdentityModel.Tokens). Esta referência conterá a chave de criptografia e o algoritmo de segurança empregados na geração de assinaturas digitais para tokens;
  • Um construtor responsável pela inicialização das propriedades Key e SigningCredentials. Este elemento fará uso, para isto, dos tipos RSACryptoServiceProvider (namespace System.Security.Cryptography), RsaSecurityKey (namespace Microsoft.IdentityModel.Tokens) e SecurityAlgorithms (namespace Microsoft.IdentityModel.Tokens), determinando assim o uso do padrão RSA como algoritmo de criptografia usado na produção de tokens.
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;

namespace APIAlturas
{
    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);
        }
    }
}

O método ConfigureServices da classe Startup também passará por ajustes:

  • Uma referência de TokenConfigurations será criada a partir do objeto vinculado à propriedade Configuration e do conteúdo definido na seção de mesmo nome no arquivo appsettings.json;
  • Instâncias dos tipos SigningConfigurations e TokenConfigurations serão configuradas via método AddSingleton, de forma que uma única referência das mesmas seja empregada durante todo o tempo em que a aplicação permanecer em execução. Quanto a UsersDAO, o método AddTransient determina que referências desta classe sejam geradas toda vez que uma dependência for encontrada;
  • Em seguida, serão invocados os métodos AddAuthentication e AddJwtBearer. A chamada a AddAuthentication especificará os schemas utilizados para a autenticação do tipo Bearer. Já em AddJwtBearer serão definidas configurações como a chave e o algoritmo de criptografia utilizados, a necessidade de analisar se um token ainda é válido e o tempo de tolerância para expiração de um token (zero, no caso desta aplicação de testes);
  • A chamada ao método AddAuthorization ativará o uso de tokens com o intuito de autorizar ou não o acesso a recursos da aplicação de testes.
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)
        {
            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();
        }
    }
}

Uma classe chamada LoginController será também implementada. Este Controller cuidará da autenticação de usuários e, caso receba credencias válidas, produzirá como resultado um token com tempo de duração de 2 minutos (120 segundos, valor configurado anteriormente no arquivo appsettings.json):

  • O método Post receberá requisições HTTP do tipo POST, tendo sido marcado com o atributo AllowAnonymous para assim possibilitar o acesso de usuários não-autenticados;
  • As instâncias dos tipos UsersDAO, SigningConfigurations e TokenConfigurations foram marcadas com o atributo FromServices no método Post, o que indica que as mesmas serão resolvidas via mecanismo de injeção de dependências do ASP.NET Core;
  • O parâmetro usuário foi marcado com o atributo FromBody, correspondendo às credenciais (ID do usuário + chave de acesso) que serão enviadas no corpo de uma requisição. As informações desta referência (usuario) serão então comparadas com o retorno produzido pela instância do tipo UsersDAO, determinando assim a validade do usuário e da chave de acesso em questão;
  • Se tratando de credenciais de um usuário existente, claims serão geradas, o período de expiração calculado e um token criado por meio de uma instância do tipo JwtSecurityTokenHandler (namespace System.IdentityModel.Tokens.Jwt). Este último elemento é então transformado em uma string por meio do método WriteToken e, finalmente, devolvido como retorno da Action Post (juntamente com outras informações como horário de geração e expiração do token);
  • Se o usuário for inválido um objeto então será devolvido, indicando que a autenticação falhou.
using System;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Principal;
using Microsoft.IdentityModel.Tokens;

namespace APIAlturas.Controllers
{
    [Route("api/[controller]")]
    public class LoginController : Controller
    {
        [AllowAnonymous]
        [HttpPost]
        public object Post(
            [FromBody]User usuario,
            [FromServices]UsersDAO usersDAO,
            [FromServices]SigningConfigurations signingConfigurations,
            [FromServices]TokenConfigurations tokenConfigurations)
        {
            bool credenciaisValidas = false;
            if (usuario != null && !String.IsNullOrWhiteSpace(usuario.UserID))
            {
                var usuarioBase = usersDAO.Find(usuario.UserID);
                credenciaisValidas = (usuarioBase != null &&
                    usuario.UserID == usuarioBase.UserID &&
                    usuario.AccessKey == usuarioBase.AccessKey);
            }
            
            if (credenciaisValidas)
            {
                ClaimsIdentity identity = new ClaimsIdentity(
                    new GenericIdentity(usuario.UserID, "Login"),
                    new[] {
                        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
                        new Claim(JwtRegisteredClaimNames.UniqueName, usuario.UserID)
                    }
                );

                DateTime dataCriacao = DateTime.Now;
                DateTime dataExpiracao = dataCriacao +
                    TimeSpan.FromSeconds(tokenConfigurations.Seconds);

                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);

                return new
                {
                    authenticated = true,
                    created = dataCriacao.ToString("yyyy-MM-dd HH:mm:ss"),
                    expiration = dataExpiracao.ToString("yyyy-MM-dd HH:mm:ss"),
                    accessToken = token,
                    message = "OK"
                };
            }
            else
            {
                return new
                {
                    authenticated = false,
                    message = "Falha ao autenticar"
                };
            }
        }
    }
}

A classe ConversorAlturasController será responsável pela conversão de alturas em pés para o equivalente em metros:

  • A Action Get foi marcada com o atributo Authorize e executará o cálculo esperado;
  • O atributo Authorize está recebendo como parâmetro o valor “Bearer”, o que indica o uso de Bearer Authentication. Requisições recebidas pelo método Get apenas serão processadas se contiverem em seu header um token válido.
using System;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;

namespace APIAlturas.Controllers
{
    [Route("api/[controller]")]
    public class ConversorAlturasController : Controller
    {
        [Authorize("Bearer")]
        [HttpGet("PesMetros/{alturaPes}")]
        public object Get(double alturaPes)
        {
            return new
            {
                AlturaPes = alturaPes,
                AlturaMetros = Math.Round(alturaPes * 0.3048, 4)
            };
        }
    }
}

O projeto descrito nesta seção já foi disponibilizado no GitHub, caso deseje efetuar o download do mesmo:

Testes via Postman

Com o projeto APIAlturas em execução, serão realizados testes via Postman, um utilitário gratuito e multiplataforma bastante popular entre desenvolvedores de APIs REST.

O primeiro passo consiste no envio de uma requisição HTTP do tipo POST para a obtenção de um token. Isto será feito através da URL http://localhost:56435/api/login, informando no corpo da solicitação uma string JSON contendo o usuário (userID) e a chave de acesso (accessKey):

Ao acionar o botão Send, será retornada uma string JSON contendo informações como a duração e o token de autenticação (este último destacado em vermelho):

Uma requisição para a conversão de uma altura de 100 pés será enviada agora. Esta solicitação do tipo GET terá como URL o valor http://localhost:56435/api/conversoralturas/pesmetros/100, fazendo uso do token de autenticação gerado no passo anterior:

Esta ação produzirá como resultado um JSON contendo a altura equivalente em metros (30,48 m):

 

Conclusão

Embora este artigo demonstre o uso de uma solução customizada para controle de usuários, outras soluções também podem ser empregadas em conjunto com JWT (JSON Web Tokens). Exemplos disto são tecnologias como ASP.NET Core Identity e AD (Active Directory).

Em um próximo artigo abordarei o consumo de APIs REST que utilizam JWT, com isto acontecendo a partir de aplicações baseadas no .NET Core.

Não deixe também de acompanhar o post a seguir, em que venho agrupando todos os conteúdos que tenho produzido sobre .NET Core 2.0 e ASP.NET Core 2.0:

Conteúdos gratuitos sobre .NET Core 2.0 e ASP.NET Core 2.0

Referências