Blazor WebAssembly - API com Autenticação JWT e Identity - I


Neste artigo veremos implementar a autenticação JWT em uma aplicação Blazor WebAssembly para acesso a uma Web API com Identity com EF Core usando a abordagem Code-First.

Se você esta chegando agora e não sabe o que é o Blazor leia o artigo ASP .NET Core - Iniciando com o Blazor - Macoratti; se você já conhece e quer saber mais pode fazer o meu curso de Blazor Essencial.  

Este artigo é uma adaptação do artigo original de Chris Sainty onde eu realizei alguns ajustes e usei o ambiente .NET Core SDK 5.0.

Neste artigo iremos usar conceitos do Identity, Migrations do EF Core e da autenticação usando o Json Web Tokens(JWT). Se você precisar conhecer mais esses conceitos veja os artigos publicados no site nas referências do artigo.

Recursos usados:

Criando um projeto Blazor Web Assembly

Abra o VS 2019 Community e selecione a opção Create a New Project;

A seguir selecione a opção Blazor WebAssembly App e clique em next;

Informe o nome da solução BlazorAppAgenda e clique em Next;

Defina as configurações conforme mostrado na figura abaixo e clique em Create;

Note que marcamos a opção para criar o projeto usando a hospedagem ASP .NET Core.

A final teremos a solução e os projetos criados em uma arquitetura monolítica de projeto único com a seguinte estrutura exibida na janela Solution Explorer:

Temos 3 projetos e vamos iniciar configurando o projeto Server onde vamos definir a configuração para implementar a autenticação JWT , a configuração do Identity , a implementação dos controladores e da API AgendaController que iremos usar para testar a implementação da autenticação.

Configurando o projeto Server :  Jwt, Identity e APIs

Vamos iniciar incluindo no projeto Server os pacotes que serão necessários para realizar toda a configuração que precisamos para realizar as implementações.

"Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.7"
"Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.6"
"Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="5.0.7"
"Microsoft.AspNetCore.Identity.UI" Version="5.0.7"
"Microsoft.EntityFrameworkCore.SqlServer" Version="5.0.7"
"Microsoft.EntityFrameworkCore.Tools" Version="5.0.7
"Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="5.0.2" />

Para incluir estes pacotes com as versões usadas você pode usar o comando :

No arquivo appsettings.json vamos incluir uma seção 'ConnectionStrings' onde vamos definir a string de conexão e a seção 'Jwt' onde vamos definir a chave secreta, o emissor, o destinatário e o tempo de expiração do token JWT que iremos usar para realizar a autenticação :

{
  "ConnectionStrings": {
    "DefaultConnection": "Sua string de conexão"
  },
  "Jwt": {
    "JwtSecurityKey": "abracadabra#simsalabim@2021",
    "JwtIssuer": "https://localhost",
    "JwtAudience": "https://localhost",
    "JwtExpiryInDays": 1
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

Na definição da string de conexão você deve informar o nome do banco de dados que será criado pelo EF Core Migrations.

Como vamos executar localmente nossa aplicação definimos o emissor e a audiência como localhost. Mas em um aplicativo de produção, o Issuer deverá ser definido ara o domínio em que a API está sendo executada e a Audience para o domínio em que o aplicativo cliente está sendo executado.

Agora vamos criar uma pasta Data no projeto Server e nesta pasta criar a classe AppDbContext que vai herdar de IdentityDbContext e que representa o contexto da aplicação com o EF Core:

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace BlazorAppAgenda.Server.Data
{
    public class AppDbContext : IdentityDbContext
    {
        public AppDbContext(DbContextOptions options) : base(options)
        {
        }
         public DbSet<Agenda> Agendamentos { get; set; }
    }
}

Como estamos usando o Identity, que precisa armazenar informações em um banco de dados, não herdamos de DbContext, mas sim de IdentityDbContext  que contém todas as configurações de que o EF Core precisa para gerenciar as tabelas do  Identity.

Nesta classe definimos o mapeamento da entidade Agenda para a tabela Agendamentos que será criada no banco de dados SQL Server quando aplicarmos o Migrations. Vamos criar a classe Agenda no projeto Shared. (Isso é opcional se você não quiser gerenciar os dados da agenda no SQL Server ou junto com as tabelas do Identity)

Precisamos agora registrar o serviço do contexto no método ConfigureServices da classe Startup do projeto Server.

 public void ConfigureServices(IServiceCollection services)
 {
       services.AddDbContext<AppDbContext>(options =>
             options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
       services.AddDefaultIdentity<IdentityUser>()
           .AddEntityFrameworkStores<AppDbContext>();
       services.AddControllersWithViews();
       services.AddRazorPages();
 }

Neste código estamos incluindo o arquivo de contexto AppDbContext na coleção de serviços e registrando os serviços do Identity informando-o para usar o EF Core como o meio de armazenamento via AppDbContext.

Também definimos o provedor para o usar o SQL Server e estamos obtendo a string de conexão definida no arquivo appsettings.json.

Antes de aplicar o Migrations vamos criar no projeto Shared a classe Agenda :

public class Agenda
{
        public int Id { get; set; }
        public string Evento { get; set; }
        public DateTime Data { get; set; }
}

Agora podemos aplicar o Migrations para criar o banco de dados e as tabelas do Identity e a tabela Agendamentos no SQL Server.

Para isso execute os comandos na seguinte ordem: (Estou usando o Package Manager Console)

Add-Migration Inicial -o Data/Migrations

Este comando irá criar a pasta Migrations dentro da pasta Data no projeto Server e criará o arquivo de script contendo as instruções para criar as tabelas do Identity e a tabela Agendamentos.

Para aplicar o script no banco de dados SQL Server digite o comando:

update-database

Ao final teremos o banco e as tabelas criadas e prontas para serem usadas no projeto.

Configurando a autenticação JWT

A próxima etapa é habilitar a autenticação JWT no projeto que iremos usar em nossa API para isso vamos incluir o código abaixo no método ConfigureServices() do arquivo Startup:

public void ConfigureServices(IServiceCollection services)
{
            services.AddDbContext<AppDbContext>(options =>
                   options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
            services.AddDefaultIdentity<IdentityUser>()
                .AddEntityFrameworkStores<AppDbContext>();
            var secretKey = Configuration["Jwt:JwtSecurityKey"];
            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
               .AddJwtBearer(options =>
               {
                   options.TokenValidationParameters = new TokenValidationParameters
                   {
                       ValidateIssuer = true,
                       ValidateAudience = true,
                       ValidateLifetime = true,
                       ValidateIssuerSigningKey = true,
                       ValidIssuer = Configuration["Jwt:JwtIssuer"],
                       ValidAudience = Configuration["Jwt:JwtAudience"],
                       IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey))
                   };
               });
            services.AddControllersWithViews();
            services.AddRazorPages();
 }

Neste código invocamos o middleware AddAuthentication() e especificamos o esquema do portador JWT para ser o esquema de autenticação. Também especificamos várias opções para o esquema do portador(bearer) JWT.

Se você observar o objeto TokenValidationParameters, verá que ele indica se o emissor, a audiência, a vida útil e a chave de assinatura devem ser validados ou não. No exemplo usamos definindo como true.

Além disso, também especificamos um emissor válido, um público-alvo válido e uma chave de assinatura válida. Esses valores são recuperados do arquivo de configuração appsettings.json onde definimos os valores para chave secreta, emissor e destinatário.

É muito importante que a JwtSecurityKey seja mantida em segredo, pois é ela que é usada para assinar os tokens produzidos pela API. Se isso for comprometido, seu aplicativo não estará mais seguro.

Não podemos esquecer de habilitar a autenticação incluindo no método Configure da classe Startup as instruções destacadas em azul:

 public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
 {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseWebAssemblyDebugging();
            }
            else
            {
                app.UseExceptionHandler("/Error");
                app.UseHsts();
            }
            app.UseHttpsRedirection();
            app.UseBlazorFrameworkFiles();
            app.UseStaticFiles();
            app.UseRouting();

            app.UseAuthentication();
            app.UseAuthorization();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapRazorPages();
                endpoints.MapControllers();
                endpoints.MapFallbackToFile("index.html");
            });
 }

Agora vamos iniciar a etapa de definição do registro e do login do usuário mas antes precisamos criar as seguintes entidades no projeto Shared:

1-UserModel

public class UserModel
{
        public string Email { get; set; }
        public bool IsAuthenticated { get; set; }
}

2-LoginModel

using System.ComponentModel.DataAnnotations;
namespace BlazorAppAgenda.Shared
{
    public class LoginModel
    {
        [Required]
        public string Email { get; set; }
        [Required]
        public string Password { get; set; }
        public bool RememberMe { get; set; }
    }
}

3-LoginResult

public class LoginResult
{
        public bool Successful { get; set; }
        public string Error { get; set; }
        public string Token { get; set; }
}

4-RegisterModel

using System.ComponentModel.DataAnnotations;

namespace BlazorAppAgenda.Shared
{
    public class RegisterModel
    {
        [Required]
        [EmailAddress]
        [Display(Name = "Email")]
        public string Email { get; set; }

        [Required]
        [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
        [DataType(DataType.Password)]
        [Display(Name = "Password")]
        public string Password { get; set; }

        [DataType(DataType.Password)]
        [Display(Name = "Confirm password")]
        [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
        public string ConfirmPassword { get; set; }
    }
}

5-RegisterResult

using System.Collections.Generic;
namespace BlazorAppAgenda.Shared
{
    public class RegisterResult
    {
        public bool Successful { get; set; }
        public IEnumerable<string> Errors { get; set; }
    }
}

Temos assim as classes que iremos usar a seguir para definir o Login e o Registro do usuário.

Definindo o Registro : Criando o controller AccountController

Para permitir que os usuários possam se autenticar vamos criar o controlador AccountController na pasta Controllers do projeto Server que será responsável por criar novas contas de usuário ou seja registrar um novo usuário.

using BlazorAppAgenda.Shared;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.Linq;
using System.Threading.Tasks;
namespace BlazorAppAgenda.Server.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class AccountController : ControllerBase
    {
        private static UserModel LoggedOutUser = new UserModel { IsAuthenticated = false };

        private readonly UserManager<IdentityUser> _userManager;
        public AccountController(UserManager<IdentityUser> userManager)
        {
            _userManager = userManager;
        }
        [HttpPost]
        public async Task<IActionResult> Post([FromBody] RegisterModel model)
        {
            var newUser = new IdentityUser { UserName = model.Email, Email = model.Email };
            var result = await _userManager.CreateAsync(newUser, model.Password);
            if (!result.Succeeded)
            {
                var errors = result.Errors.Select(x => x.Description);
                return Ok(new RegisterResult { Successful = false, Errors = errors });
            }
            return Ok(new RegisterResult { Successful = true });
        }
    }
}

Neste controlador estamos criando uma instância de UserModel definindo o usuário como não autenticado.

A seguir estamos injetando no construtor uma instância de UserManager<T> a partir da qual iremos usar o método CreateAsync() para criar um novo usuário no método Action Post do controlador.

Definindo o Login : Criando o controller LoginController

Vamos agora criar o controlador que vai permitir realizar a autenticação de usuário existente fazendo o login. Se o login for realizado com sucesso iremos criar o token JWT para retornar com LoginResult.

using BlazorAppAgenda.Shared;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;

namespace BlazorAppAgenda.Server.Controllers
{
    [Route("api/[controller]")]
    [ApiController]

    public class LoginController : ControllerBase
    {
        private readonly IConfiguration _configuration;
        private readonly SignInManager<IdentityUser> _signInManager;
        public LoginController(IConfiguration configuration,
                               SignInManager<IdentityUser> signInManager)
        {
            _configuration = configuration;
            _signInManager = signInManager;
        }

        [HttpPost]
        public async Task<IActionResult> Login([FromBody] LoginModel login)
        {
            var result = await _signInManager.PasswordSignInAsync(login.Email, login.Password, false, false);

            if (!result.Succeeded) return BadRequest(new LoginResult
            {
                Successful = false, Error = "Username and password are invalid."
            });

            var claims = new[]
            {
                new Claim(ClaimTypes.Name, login.Email)
            };

            var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:JwtSecurityKey"]));
            var
creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
            var
expiry = DateTime.Now.AddDays(Convert.ToInt32(_configuration["Jwt:JwtExpiryInDays"]));

            var token = new JwtSecurityToken(
                _configuration["
Jwt:JwtIssuer"],
                _configuration["
Jwt:JwtAudience"],
                claims,
                expires:
expiry,
                signingCredentials:
creds
            );

            return Ok(new LoginResult
            {
               Successful = true,
Token = new JwtSecurityTokenHandler().WriteToken(token)
            });

        }
    }
}

No construtor do controlador estamos injetando instâncias de SignInManager e Configuration que iremos usar para validar o login do usuário e para obter informações do arquivo de configuração appsetings.json.

Após validar o usuário criamos uma nova SymmetricSecurityKey com base na chave secreta.

O objeto SigningCredentials é gerado com base no SymmetricSecurityKey. Observe que usamos o algoritmo HS256 para gerar a assinatura digital.

Agora podemos seguir em frente e criar um token JWT. Isso é feito usando a classe JwtSecurityToken. Passamos o emissor, o audience(público-alvo), e data de expiração para o token e as credenciais de assinatura obtidas do arquivo appsettings.json.

Queremos o JWT em um formato de string para que possa ser facilmente enviado ao cliente. Isso é feito usando a classe JwtSecurityTokenHandler. O método WriteToken() aceita um JwtSecurityToken criado anteriormente e o retorna como uma string de formato JSON compactada.

Com isso a parte da configuração para autenticação JWT esta pronta. Vamos criar agora a nossa API AgendaController que iremos usar para testar a autenticação.

Na pasta Controllers crie o controlador AgendaController com o código abaixo:

using BlazorAppAgenda.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
namespace BlazorAppAgenda.Server.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class AgendaController : ControllerBase
    {
        [HttpGet, Authorize]
        public IActionResult GetAgendamentos()
        {
            List<Agenda> agenda = new List<Agenda>();
            agenda.Add(new Agenda()
            {
                Id = 1,
                Evento = "Curso DDD na prática",
                Data = System.DateTime.Now
            });
            agenda.Add(new Agenda()
            {
                Id = 1,
                Evento = "Evento - Design Patterns",
                Data = System.DateTime.Now.AddDays(15)
            });
            agenda.Add(new Agenda()
            {
                Id = 1,
                Evento = "Palestra - Os recursos do .NET 6.0",
                Data = System.DateTime.Now.AddDays(30)
            });
            return new ObjectResult(agenda);
        }
    }
}

Definimos o método HttpGet - GetAgendamentos() - que retorna uma lista de agendamentos.

Observe que usamos o atributo [Authorize] no endpoint da API e assim somente usuários autenticados poderão acessar esse endpoint.

Dessa forma temos toda a infraestrutura para realizar a autenticação JWT preparada vamos agora configurar o projeto Client.

Na próxima parte do artigo vamos configurar o projeto Client e criar os componentes.

Referências:


José Carlos Macoratti