.NET

5 fev, 2018

ASP.NET Core 2.0: JWT + Identity Core na autenticação de APIs

Publicidade

O uso de JSON Web Tokens (JWT) constitui uma das práticas mais comuns para garantir um acesso seguro a APIs REST. Partindo de tokens criptografados com um tempo de validade pré-definido, esta técnica é suportada pelas principais plataformas de desenvolvimento da atualidade.

Já abordei, inclusive, em um artigo anterior, a utilização de JWT como mecanismo de autenticação em APIs REST construídas com o ASP.NET Core 2.0. O artigo em questão fazia uso de um banco de dados contendo usuários e chaves de acesso, além de demonstrar os passos necessários para a geração de tokens e como proteger o acesso a recursos disponibilizados por uma API:

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

O banco de dados mencionado corresponde a um controle customizado para gerenciamento de usuários e credenciais de acesso. Outras opções similares podem ser empregadas e o ASP.NET Core Identity constitui um bom exemplo neste sentido.

Fazendo uso de bases relacionais e do Entity Framework Core como tecnologia de acesso a dados, o ASP.NET Core Identity conta com funcionalidades e recursos que simplificam a implementação de controles de segurança em aplicações Web construídas com o .NET Core.

Este novo artigo foca no uso de JWT em conjunto com o ASP.NET Identity Core, a fim de garantir o acesso seguro a APIs REST baseadas no ASP.NET Core 2.0.

Aproveito este artigo para deixar aqui também um convite. No dia 29/01 (segunda) às 22h00 — horário de Brasília — farei uma apresentação no Canal .NET sobre as novidades do C# 7.2, juntamente com o MVP André Secco e utilizando para isto o release mais recente do Visual Studio 2017 (Update 15.5).

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.

Habilitando o uso do Identity em projetos ASP.NET: uma visão geral

Ao criar um novo projeto do tipo Web Application ou Web Application (Model-View-Controller) a partir do Visual Studio 2017, podemos definir como será a autenticação através da opção Change Authentication:

Selecionando então o modo Individual User Accounts e, na sequência, a opção Store user accounts in-app:

A confirmação destes procedimentos resultará na criação de tipos como ApplicationDbContext e ApplicationUser, além de outras classes, Controllers e Views empregados no gerenciamento de usuários e controle de acesso:

Já no caso de projetos Web API, não contamos com a opção Store user accounts in-app ao empregar o modo Individual User Accounts:

Esta característica trará a necessidade de implementar manualmente em nossas APIs algumas estruturas e instruções para integração com o ASP.NET Core Identity. As próximas seções descrevem em detalhes os ajustes necessários para se atingir tal objetivo.

Alguns detalhes sobre a aplicação de exemplo

A API apresentada neste artigo já foi detalhada no primeiro artigo que publiquei sobre JWT, fornecendo uma funcionalidade para a conversão de alturas em pés (unidade comumente utilizada na aviação) para o equivalente em metros.

Os fontes do novo projeto que combina o uso de JWT com o ASP.NET Core Identity já se encontram no GitHub (esta aplicação foi criada empregando o ASP.NET Core 2.0):

Implementando a integração com o ASP.NET Core Identity

No arquivo appsettings.json será incluída a string de conexão para acesso ao banco BaseIdentity (que conterá as tabelas das quais o ASP.NET Core Identity depende), bem como configurações para a geração do token (Audience, Issuer e tempo de duração em segundos):

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

A classe ApplicationUser herda do tipo IdentityUser (namespace Microsoft.AspNetCore.Identity), correspondendo à implementação dos dados de um usuário registrado em uma base configurada para uso do ASP.NET Core Identity:

using Microsoft.AspNetCore.Identity;

namespace APIAlturas.Models
{
    public class ApplicationUser : IdentityUser
    {
    }
}

Na próxima listagem está a implementação das seguintes estruturas:

  • User: classe empregada na manipulação de credenciais de usuários;
  • Roles: tipo estático contendo o nome da role de acesso (Acesso-APIAlturas) à API de conversão de alturas;
  • 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 carregadas a partir do arquivo appsettings.json.
namespace APIAlturas
{
    public class User
    {
        public string UserID { get; set; }
        public string Password { get; set; }
    }

    public static class Roles
    {
        public const string ROLE_API_ALTURAS = "Acesso-APIAlturas";
    }

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

Já a classe de contexto ApplicationDbContext implementa o tipo genérico IdentityDbContext (namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore), referenciando também ApplicationUser em sua definição. Esta construção baseada no Entity Framework Core acessará o banco BaseIdentity, sendo empregada pelas demais estruturas do ASP.NET Identity Core para gerenciar dados de usuários e permissões:

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using APIAlturas.Models;

namespace APIAlturas.Data
{
    public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder builder)
        {
            base.OnModelCreating(builder);
        }
    }
}

A classe IdentityInitializer será responsável pela inicialização dos dados para utilização do ASP.NET Identity Core:

  • O construtor de IdentityInitializer receberá instâncias dos tipos ApplicationDbContext, UserManager (namespace Microsoft.AspNetCore.Identity) e RoleManager (namespace Microsoft.AspNetCore.Identity);
  • O método Initialize criará, caso ainda não existam, as estruturas de dados utilizadas pelo Identity (o que inclui o próprio banco de dados, indicado em appsettings.json pelo nome BaseIdentity). Isto acontecerá através de uma chamada ao método EnsureCreated do objeto Database vinculado a ApplicationDbContext;
  • Já a instância do tipo RoleManager será empregada na criação da role Acesso-APIAlturas (se a mesma ainda não existir);
  • As chamadas ao método CreateUser registrarão os usuários admin_apialturas (com permissão de acesso à API de conversão de alturas) e usrinvalido_apialturas (sem permissão), utilizando para tanto a instância da classe UserManager.
using System;
using Microsoft.AspNetCore.Identity;
using APIAlturas.Models;

namespace APIAlturas.Data
{
    public class IdentityInitializer
    {
        private readonly ApplicationDbContext _context;
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly RoleManager<IdentityRole> _roleManager;
        

        public IdentityInitializer(
            ApplicationDbContext context,
            UserManager<ApplicationUser> userManager,
            RoleManager<IdentityRole> roleManager)
        {
            _context = context;
            _userManager = userManager;
            _roleManager = roleManager;
        }

        public void Initialize()
        {
            if (_context.Database.EnsureCreated())
            {
                if (!_roleManager.RoleExistsAsync(Roles.ROLE_API_ALTURAS).Result)
                {
                    var resultado = _roleManager.CreateAsync(
                        new IdentityRole(Roles.ROLE_API_ALTURAS)).Result;
                    if (!resultado.Succeeded)
                    {
                        throw new Exception(
                            quot;Erro durante a criação da role {Roles.ROLE_API_ALTURAS}.");
                    }
                }

                CreateUser(
                    new ApplicationUser()
                    {
                        UserName = "admin_apialturas",
                        Email = "admin-apialturas@teste.com.br",
                        EmailConfirmed = true
                    }, "AdminAPIAlturas01!", Roles.ROLE_API_ALTURAS);

                CreateUser(
                    new ApplicationUser()
                    {
                        UserName = "usrinvalido_apialturas",
                        Email = "usrinvalido-apialturas@teste.com.br",
                        EmailConfirmed = true
                    }, "UsrInvAPIAlturas01!");
            }
        }

        private void CreateUser(
            ApplicationUser user,
            string password,
            string initialRole = null)
        {
            if (_userManager.FindByNameAsync(user.UserName).Result == null)
            {
                var resultado = _userManager
                    .CreateAsync(user, password).Result;

                if (resultado.Succeeded &&
                    !String.IsNullOrWhiteSpace(initialRole))
                {
                    _userManager.AddToRoleAsync(user, initialRole).Wait();
                }
            }
        }
    }
}

O tipo SigningConfigurations já foi detalhado no artigo anterior e não passará por mudanças. É responsabilidade desta estrutura a geração da chave e da assinatura empregadas na criaçã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);
        }
    }
}

Ajustes serão realizados também na classe Startup, a fim de possibilitar o uso do ASP.NET Identity Core (as instruções necessárias para a utilização de tokens já foram descritas no primeiro artigo sobre JWT, o qual foi mencionado no início deste artigo):

No método ConfigureServices serão invocados os métodos AddDbContext (configurando o uso da classe ApplicationDbContext) e AddIdentity (habilitando a utilização das estruturas do Identity na API via injeção de dependências);

A chamada ao método Initialize de IdentityInitializer na operação Configure permitirá a criação de estruturas, usuários e permissões na base designada para uso do ASP.NET Identity Core (caso tais elementos ainda não existam). Em um cenário ideal, esta instrução seria evitada, já que o gerenciamento de usuários aconteceria separadamente da API REST aqui descrita (em uma aplicação Web isolada que acessa um banco de dados pré-existente, por exemplo).

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;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using APIAlturas.Data;
using APIAlturas.Models;

namespace APIAlturas
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            // Configurando o uso da classe de contexto para
            // acesso às tabelas do ASP.NET Identity Core
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(Configuration.GetConnectionString("BaseIdentity")));

            // Ativando a utilização do ASP.NET Identity, a fim de
            // permitir a recuperação de seus objetos via injeção de
            // dependências
            services.AddIdentity<ApplicationUser, IdentityRole>()
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddDefaultTokenProviders();

            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,
            ApplicationDbContext context,
            UserManager<ApplicationUser> userManager,
            RoleManager<IdentityRole> roleManager)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            // Criação de estruturas, usuários e permissões
            // na base do ASP.NET Identity Core (caso ainda não
            // existam)
            new IdentityInitializer(context, userManager, roleManager)
                .Initialize();

            app.UseMvc();
        }
    }
}

O tipo LoginController passará pelas seguintes alterações (novamente foram omitidas as explicações quanto à geração de tokens, descritas no artigo anterior sobre JWT):

  • Instâncias dos tipos UserManager e SignInManager (namespace Microsoft.AspNetCore.Identity) serão recebidas via injeção de dependências no método Post;
  • A referência de UserManager será empregada para validar a existência de um usuário (cujas credenciais foram informadas na instância do tipo User);
  • Já o objeto do tipo SignInManager validará a senha informada, utilizando para isto o método CheckPasswordSignInAsync.
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;
using Microsoft.AspNetCore.Identity;
using APIAlturas.Models;

namespace APIAlturas.Controllers
{
    [Route("api/[controller]")]
    public class LoginController : Controller
    {
        [AllowAnonymous]
        [HttpPost]
        public object Post(
            [FromBody]User usuario,
            [FromServices]UserManager<ApplicationUser> userManager,
            [FromServices]SignInManager<ApplicationUser> signInManager,
            [FromServices]SigningConfigurations signingConfigurations,
            [FromServices]TokenConfigurations tokenConfigurations)
        {
            bool credenciaisValidas = false;
            if (usuario != null && !String.IsNullOrWhiteSpace(usuario.UserID))
            {
                // Verifica a existência do usuário nas tabelas do
                // ASP.NET Core Identity
                var userIdentity = userManager
                    .FindByNameAsync(usuario.UserID).Result;
                if (userIdentity != null)
                {
                    // Efetua o login com base no Id do usuário e sua senha
                    var resultadoLogin = signInManager
                        .CheckPasswordSignInAsync(userIdentity, usuario.Password, false)
                        .Result;
                    if (resultadoLogin.Succeeded)
                    {
                        // Verifica se o usuário em questão possui
                        // a role Acesso-APIAlturas
                        credenciaisValidas = userManager.IsInRoleAsync(
                            userIdentity, Roles.ROLE_API_ALTURAS).Result;
                    }
                }
            }
            
            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 permanecerá inalterada, tendo por função a conversão de alturas em pés para o equivalente em metros (o acesso à Action Get será permitido apenas se as requisições recebidas conterem 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)
            };
        }
    }
}

Testes

Ao acionar a execução do projeto APIAlturas, serão criadas as estruturas esperadas para o banco BaseIdentity (e eventualmente o próprio) caso tais construções ainda não existam. A consulta a seguir mostra que foram populadas as tabelas dbo.AspNetUsers, dbo.AspNetRoles e dbo.AspNetUserRoles:

Consultando o banco BaseIdentity via SQL Operations Studio

Testes de acesso à API serão realizados então via Postman. Uma primeira simulação será feita por meio da URL http://localhost:56435/api/login, informando no corpo desta solicitação do tipo POST uma string JSON contendo o usuário admin-apialturas (userID) e sua respectiva senha (password). No retorno produzido pela API constará o token de acesso:

Uma requisição GET para a conversão de uma altura de 100 pés será enviada agora, utilizando o token obtido no passo anterior. Esta solicitação terá como URL o valor http://localhost:56435/api/conversoralturas/pesmetros/100, sendo que o resultado da mesma pode ser observado na próxima imagem:

Um teste com o usuário usrinvalido_apialturas indicará que o mesmo não possui acesso à API de conversão de alturas:

Caso necessite consumir APIs REST que utilizam JWT em .NET Core, acesse o seguinte artigo:

E para concluir este artigo, não deixe também de acompanhar o artigo a seguir, no qual venho agrupando todos os conteúdos que tenho produzido sobre .NET Core 2.0 e ASP.NET Core 2.0:

Referências