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:
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: