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


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.

Continuando a primeira parte do artigo iremos agora configurar o projeto Client definindo os serviços e criando os componentes.

Configurando o projeto Client : Criando os serviços e os componentes

Vamos iniciar a configuração do projeto Client incluindo o pacote Blazored.LocalStorage que iremos usar para persistir o token de autenticação a partir da API quando o usuário fizer o login.

Para isso inclua o pacote Nuget abaixo usando o comando : Install-Package ou dotnet add package

Blazored.LocalStorage" Version="4.1.2"

A seguir vamos atualizar o componente App do projeto Client para usar o componente AuthorizeRouteView ao invés do componente RouteView :

   <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
        </Found>
        <NotFound>
           
<CascadingAuthenticationState>
                <LayoutView Layout="@typeof(MainLayout)">
                    <p>Sorry, there's nothing at this address.</p>
                </LayoutView>
            </CascadingAuthenticationState>
        </NotFound>
   </Router>

O componente AuthorizeRouteView se comporta da mesma forma que RouteView, mas exibe apenas as páginas que o usuário esta autorizado a ver. Além usamos o componente CascadingAuthenticationState que fornece um parâmetro em cascata do tipo Task<AuthtenticationState> para todos os componentes descendentes que é usado pelo componente AuthorizeRouteView para determinar o estado de autenticação atual do usuário.

Criando um AuthenticationStateProvider customizado

Como estamos usando o Blazor do lado do cliente, precisamos fornecer nossa própria implementação para a classe AuthenticationStateProvider. Como há tantas opções quando se trata de aplicativos do lado do cliente, não há como projetar uma classe padrão que funcione para todos.

Precisamos substituir o método GetAuthenticationStateAsync. Neste método, precisamos determinar se o usuário atual esta autenticado ou não. Também vamos incluir alguns métodos auxiliares que usaremos para atualizar o estado de autenticação quando o usuário efetuar login ou logout.

Assim na raiz do projeto (ou em uma pasta separada se preferir) crie a classe ApiAuthenticationStateProvider com o código a seguir:

using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.Authorization;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;

namespace BlazorAppAgenda.Client
{
    public class ApiAuthenticationStateProvider : AuthenticationStateProvider
    {
        private readonly HttpClient _httpClient;
        private readonly ILocalStorageService _localStorage;

        public ApiAuthenticationStateProvider(HttpClient httpClient, ILocalStorageService localStorage)
        {
            _httpClient = httpClient;
            _localStorage = localStorage;

        }
        public override async Task<AuthenticationState> GetAuthenticationStateAsync()
        {
            var savedToken = await _localStorage.GetItemAsync<string>("authToken");

            if (string.IsNullOrWhiteSpace(savedToken))
            {
                return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
            }

            _httpClient.DefaultRequestHeaders.Authorization =
                 new AuthenticationHeaderValue("bearer", savedToken);

            return new AuthenticationState(new ClaimsPrincipal(
                new ClaimsIdentity(ParseClaimsFromJwt(savedToken), "jwt")));
        }

        public void MarkUserAsAuthenticated(string email)
        {
            var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
            {
               new Claim(ClaimTypes.Name, email)
            }, "apiauth"));

            var authState = Task.FromResult(new AuthenticationState(authenticatedUser));
            NotifyAuthenticationStateChanged(authState);
        }

        public void MarkUserAsLoggedOut()
        {
            var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
            var authState = Task.FromResult(new AuthenticationState(anonymousUser));
            NotifyAuthenticationStateChanged(authState);
        }

        private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
        {
            var claims = new List<Claim>();
            var payload = jwt.Split('.')[1];
            var jsonBytes = ParseBase64WithoutPadding(payload);
            var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);

            keyValuePairs.TryGetValue(ClaimTypes.Role, out object roles);

            if (roles != null)
            {
                if (roles.ToString().Trim().StartsWith("["))
                {
                    var parsedRoles = JsonSerializer.Deserialize<string[]>(roles.ToString());

                    foreach (var parsedRole in parsedRoles)
                    {
                        claims.Add(new Claim(ClaimTypes.Role, parsedRole));
                    }
                }
                else
                {
                    claims.Add(new Claim(ClaimTypes.Role, roles.ToString()));
                }

                keyValuePairs.Remove(ClaimTypes.Role);
            }

            claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString())));

            return claims;
        }

        private byte[] ParseBase64WithoutPadding(string base64)
        {
            switch (base64.Length % 4)
            {
                case 2: base64 += "=="; break;
                case 3: base64 += "="; break;
            }
            return Convert.FromBase64String(base64);
        }
    }
}

Vamos entender o código desta classe:

O método GetAuthenticationStateAsync é chamado pelo componente CascadingAuthenticationState para determinar se o usuário atual está autenticado ou não.

No código, verificamos se existe um token de autenticação no armazenamento local. Se não houver um token no armazenamento local, retornamos um novo AuthenticationState com um claims principal em branco. Isso equivale a dizer que o usuário atual não está autenticado.

Se houver um token, nós o recuperamos e definimos o cabeçalho de autorização padrão para o HttpClient. Em seguida, retornamos um novo AuthenticationState com um novo claims principal  contendo as declarações do token. As declarações são extraídas do token pelo método ParseClaimsFromJwt. Este método decodifica o token e retorna as declarações contidas nele.

O método MarkUserAsAuthenticated é um método auxiliar usado para quando um usuário efetua login. Seu único propósito é invocar o método NotifyAuthenticationStateChanged que dispara um evento chamado AuthenticationStateChanged. Isso coloca em cascata o novo estado de autenticação, por meio do componente CascadingAuthenticationState.

Como você pode esperar, MarkUserAsLoggedOut faz quase exatamente o mesmo que o método anterior, mas quando um usuário efetua logout.

Criando os serviço de Autenticação

Vamos agora criar o serviço de autenticação que será usado nos componentes Razor para registrar e logar os usuários.

No projeto Client crie a pasta Services e nesta pasta crie primeiro a interface IAuthService :

using BlazorAppAgenda.Shared;
using System.Threading.Tasks;

namespace BlazorAppAgenda.Client.Services
{
    public interface IAuthService
    {
        Task<LoginResult> Login(LoginModel loginModel);
        Task Logout();
        Task<RegisterResult> Register(RegisterModel registerModel);
    }
}

Aqui definimos um contrato para implementar o Login, o Logout e o Registro de um novo usuário.

Na mesma pasta crie a classe AuthService que implementa a interface:

using BlazorAppAgenda.Shared;
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.Authorization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace BlazorAppAgenda.Client.Services
{
    public class AuthService : IAuthService
    {
        private readonly HttpClient _httpClient;
        private readonly AuthenticationStateProvider _authenticationStateProvider;
        private readonly ILocalStorageService _localStorage;
        public AuthService(HttpClient httpClient,
                           AuthenticationStateProvider authenticationStateProvider,
                           ILocalStorageService localStorage)
        {
            _httpClient = httpClient;
            _authenticationStateProvider = authenticationStateProvider;
            _localStorage = localStorage;
        }
        public async Task<RegisterResult> Register(RegisterModel registerModel)
        {
            var messageResult = await _httpClient.PostAsJsonAsync("api/account", registerModel);
            var result = await messageResult.Content.ReadFromJsonAsync<RegisterResult>();
            return result;
        }
        public async Task<LoginResult> Login(LoginModel loginModel)
        {
            var loginAsJson = JsonSerializer.Serialize(loginModel);
            var response = await _httpClient.PostAsync("api/Login",
                new StringContent(loginAsJson, Encoding.UTF8, "application/json"));
            var loginResult = JsonSerializer.Deserialize<LoginResult>(await
                response.Content.ReadAsStringAsync(), new JsonSerializerOptions
                { PropertyNameCaseInsensitive = true });
            if (!response.IsSuccessStatusCode)
            {
                return loginResult;
            }
            await _localStorage.SetItemAsync("authToken", loginResult.Token);
            ((ApiAuthenticationStateProvider)_authenticationStateProvider)
                .MarkUserAsAuthenticated(loginModel.Email);
            _httpClient.DefaultRequestHeaders.Authorization = new
                AuthenticationHeaderValue("bearer", loginResult.Token);
            return loginResult;
        }
        public async Task Logout()
        {
            await _localStorage.RemoveItemAsync("authToken");
            ((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsLoggedOut();
            _httpClient.DefaultRequestHeaders.Authorization = null;
        }
    }
}

O método Register envia o registerModel ao controlador de contas e retorna o RegisterResult ao chamador.

O método Login é semelhante ao método Register, ele posta o LoginModel no controlador de login. Mas quando um resultado bem-sucedido é retornado, ele remove o token de autenticação e o persiste no armazenamento local.

Em seguida, ele chama o método MarkUserAsAuthenticated que acabamos de criar na classe ApiAuthenticationStateProvider. Finalmente, ele define o cabeçalho de autorização padrão no HttpClient.

O método Logout está apenas fazendo o inverso do método Login.

Agora só nos resta criar os componentes Blazor e usar os serviços.

Criando o componente Register.razor

Agora vamos criar os componentes Blazor para definir a interface com usuário e usar os serviços e toda a infraestrutura criada para fazer  a 'mágica acontecer'.

Na pasta Pages do projeto Client crie o componente Register.razor:

@page "/register"
@inject IAuthService AuthService
@inject NavigationManager NavigationManager

<h1>Register</h1>

@if (ShowErrors)
{
    <div class="alert alert-danger" role="alert">
        @foreach (var error in Errors)
        {
            <p>@error</p>
        }
    </div>
}

<div class="card">
    <div class="card-body">
        <h5 class="card-title">Please enter your details</h5>
        <EditForm Model="RegisterModel" OnValidSubmit="HandleRegistration">
            <DataAnnotationsValidator />
            <ValidationSummary />

            <div class="form-group">
                <label for="email">Email address</label>
                <InputText Id="email" class="form-control" @bind-Value="RegisterModel.Email" />
                <ValidationMessage For="@(() => RegisterModel.Email)" />
            </div>
            <div class="form-group">
                <label for="password">Password</label>
                <InputText Id="password" type="password" class="form-control"
@bind-Value="RegisterModel.Password"
/>
                <ValidationMessage For="@(() => RegisterModel.Password)" />
            </div>
            <div class="form-group">
                <label for="password">Confirm Password</label>
                <InputText Id="password" type="password" class="form-control"
@bind-Value="RegisterModel.ConfirmPassword"
/>
                <ValidationMessage For="@(() => RegisterModel.ConfirmPassword)" />
            </div>
            <button type="submit" class="btn btn-primary">Submit</button>
        </EditForm>
    </div>
</div>

@code {

    private RegisterModel RegisterModel = new RegisterModel();
    private bool ShowErrors;
    private IEnumerable<string> Errors;

    private async Task HandleRegistration()
    {
        ShowErrors = false;

        var result = await AuthService.Register(RegisterModel);

        if (result.Successful)
        {
            NavigationManager.NavigateTo("/login");
        }
        else
        {
            Errors = result.Errors;
            ShowErrors = true;
        }
    }
}

Este componente contém um formulário que permite ao usuário inserir seu endereço de e-mail e a senha desejada. Quando o formulário é submetido, o método Register no AuthService é chamado de passando o RegisterModel. Se o resultado do registro for um sucesso, o usuário é direcionando para a página de login. Caso contrário, quaisquer erros são exibidos para o usuário.

Criando o componente Login.razor

O componente Login.razor será criado na mesma pasta Pages e será responsável por autenticar o usuário existente.

@page "/login"
@inject IAuthService AuthService
@inject NavigationManager NavigationManager

<h1>Login</h1>

@if (ShowErrors)
{
    <div class="alert alert-danger" role="alert">
        <p>@Error</p>
    </div>
}

<div class="card">
    <div class="card-body">
        <h5 class="card-title">Please enter your details</h5>
        <EditForm Model="loginModel" OnValidSubmit="HandleLogin">
            <DataAnnotationsValidator />
            <ValidationSummary />

            <div class="form-group">
                <label for="email">Email address</label>
                <InputText Id="email" Class="form-control" @bind-Value="loginModel.Email" />
                <ValidationMessage For="@(() => loginModel.Email)" />
            </div>
            <div class="form-group">
                <label for="password">Password</label>
                <InputText Id="password" type="password" Class="form-control"
@bind-Value
="loginModel.Password" />
                <ValidationMessage For="@(() => loginModel.Password)" />
            </div>
            <button type="submit" class="btn btn-primary">Submit</button>
        </EditForm>
    </div>
</div>

@code {

    private LoginModel loginModel = new LoginModel();
    private bool ShowErrors;
    private string Error = "";

    private async Task HandleLogin()
    {
        ShowErrors = false;

        var result = await AuthService.Login(loginModel);

        if (result.Successful)
        {
            NavigationManager.NavigateTo("/");
        }
        else
        {
            Error = result.Error;
            ShowErrors = true;
        }
    }
}

Neste componente existe um formulário para o usuário inserir seu endereço de e-mail e senha.

Quando o formulário é enviado, o AuthService é chamado e o resultado é retornado. Se o login for bem-sucedido, o usuário será redirecionado para a página inicial, caso contrário, será exibida a mensagem de erro.

Criando o componente Logout.razor

Este componente permite realizar o Logout do usuário :

@page "/logout"
@inject IAuthService AuthService
@inject NavigationManager NavigationManager


@code {

    protected override async Task OnInitializedAsync()
    {
        await AuthService.Logout();
        NavigationManager.NavigateTo("/");
    }
}

Criando o componente Agendamentos.razor

Este componente é opcional e apenas vai exibir uma lista de agendamentos obtidos a partir da API AgendaController:

@page "/agenda"
@inject JsonPlaceHolderAgenda _jsonPlaceHolderAgenda;

<h1>Agendamentos</h1>

@if (agendamentos != null)
{
    <table class="table table-striped">
        <thead>
            <tr>
                <th scope="col">Id</th>
                <th scope="col">Evento</th>
                <th scope="col">Data</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var agenda in agendamentos)
            {
                <tr>
                    <td>@agenda.Id</td>
                    <td>@agenda.Evento</td>
                    <td>@agenda.Data</td>
                </tr>
            }
        </tbody>
    </table>
}
else
{
    <h1>Não Autorizado !!!</h1>
}

@code{
    private List<Agenda> agendamentos = new List<Agenda>();

    protected override async Task OnInitializedAsync()
    {
        agendamentos = await _jsonPlaceHolderAgenda.GetAgendamentos();
    }
}

Neste código estamos acessando a API usando uma instância da classe JsonPlaceHolderAgenda. Assim precisamos criar esta classe na pasta Services:

using BlazorAppAgenda.Shared;
using Microsoft.AspNetCore.Components;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;

namespace BlazorAppAgenda.Client.Services
{
    public class JsonPlaceHolderAgenda
    {
        private HttpClient _httpClient;
        private NavigationManager _navigationManager;
        public JsonPlaceHolderAgenda(HttpClient httpClient, NavigationManager navigationManager)
        {
            _httpClient = httpClient;
            _navigationManager = navigationManager;
        }

        public async Task<List<Agenda>> GetAgendamentos()
        {
            using var response = await _httpClient.GetAsync("/api/agenda");

            if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
            {
                _navigationManager.NavigateTo("notauthorized");
            }

            var result = await _httpClient.GetFromJsonAsync<List<Agenda>>("/api/agenda");
            return result;
        }
    }
}

Neste código temos o método GetAgendamentos que retorna a lista da agenda e acessa o endpoint /api/agenda. Note que se o usuário não estiver autorizado estamos navegando para o componente de rota 'notauthorized'.

Vamos criar o componente NotAuthorized.razor na pasta Pages apenas para informar que o usuário não esta autorizado:

@page "/notauthorized"

<h1>Usuário Não Autorizado</h1>

Criando o componente LoginDisplay e atualizando o MainLayout

Como tarefa final vamos adicionar um componente chamado LoginDisplay na pasta Shared do projeto Client e, em seguida, atualizar o componente MainLayout para usá-lo.

O componente LoginDisplay vai permitir mostrar os links para Registro e Login se o usuário não estiver autenticado e exibir o email do usuário logado e o link para fazer o logout:

<AuthorizeView>
    <Authorized>
        Hello, @context.User.Identity.Name!
        <a href="LogOut">Log out</a>
    </Authorized>
    <NotAuthorized>
        <a href="Register">Register</a>
        <a href="Login">Log in</a>
    </NotAuthorized>
</AuthorizeView>

Agora temos que atualizar o arquivo MainLayout usando este componente para exibir os links:

@inherits LayoutComponentBase

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <div class="main">
        <div class="top-row px-4">
            <LoginDisplay />
            <a href="http://blazor.net" target="_blank" class="ml-md-auto">About</a>
        </div>

        <div class="content px-4">
            @Body
        </div>
    </div>
</div>

Agora  só falta registrar todos os serviços criados no projeto no arquivo Program do projeto Client:

using BlazorAppAgenda.Client.Services;
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Net.Http;
using System.Threading.Tasks;

namespace BlazorAppAgenda.Client
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("#app");

            builder.Services.AddScoped(sp => new HttpClient
            {
                BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
            });

            builder.Services.AddBlazoredLocalStorage();
            builder.Services.AddAuthorizationCore();
            builder.Services.AddScoped<AuthenticationStateProvider, ApiAuthenticationStateProvider>();
            builder.Services.AddScoped<IAuthService, AuthService>();
            builder.Services.AddScoped<JsonPlaceHolderAgenda>();

            await builder.Build().RunAsync();
        }
    }
}

Nota:  Eu atualizei o componente Index.razor para exibir uma imagem e também atualizei o componente NavMenu.razor para exibir o menu com apenas dois links.

Agora é só alegria...

Vamos executar o projeto e testar ...

Executando o projeto inicialmente teremos o seguinte resultado:

Ao tentar acessar a lista de agendamentos, como não estamos logados iremos receber o resultado abaixo:

Vamos então clicar no link Register e criar um novo usuário:

Fazendo o registro do usuário com sucesso seremos redirecionados para a página de Login:

Fazendo o Login com sucesso teremos a página a seguir:

Note o email do usuário e o link par Logout sendo exibidos. 

Acessando o link para exibir os agendamentos teremos o resultado a seguir:

Examinando o LocalStorage da aplicação no navegador Chrome podemos constatar o token JWT sendo armazenado:

Pegue o projeto aqui :  BlazorAppAgenda.zip  (sem as referências)

"Bem-aventurados os pobres de espírito, porque deles é o reino dos céus; Bem-aventurados os que choram, porque eles serão consolados; "
Mateus 5:3,4

Referências:


José Carlos Macoratti