Exercício – Usar declarações com autorização baseada em políticas

Concluído

Na unidade anterior, você aprendeu a diferença entre autenticação e autorização. Você também aprendeu como as declarações são usadas por políticas para autorização. Nesta unidade, você vai usar o Identity para armazenar declarações e aplicar políticas para acesso condicional.

Proteger a lista de pizzas

Você recebeu um novo requisito de que a página Lista de Pizzas deve estar visível apenas para usuários autenticados. Além disso, apenas administradores têm permissão para editar, criar e excluir produtos. Vamos bloqueá-la.

  1. Em Pages/Pizza.cshtml.cs, aplique as seguintes alterações:

    1. Adicione um atributo [Authorize] à classe PizzaModel.

      [Authorize]
      public class PizzaModel : PageModel
      

      O atributo descreve os requisitos de autorização de usuário da página. Nesse caso, não há requisitos além do usuário que está sendo autenticado. Os usuários anônimos não têm permissão para ver a página e são redirecionados à página de entrada.

    2. Resolva a referência a Authorize adicionando a seguinte linha às diretivas using na parte superior do arquivo:

      using Microsoft.AspNetCore.Authorization;
      
    3. Adicione a seguinte propriedade à classe PizzaModel:

      [Authorize]
      public class PizzaModel : PageModel
      {
          public bool IsAdmin => HttpContext.User.HasClaim("IsAdmin", bool.TrueString);
      
          public List<Pizza> pizzas = new();
      

      O código anterior determina se o usuário autenticado tem uma declaração IsAdmin com um valor igual a True. O código obtém informações sobre o usuário autenticado do HttpContext na classe PageModel pai. O resultado dessa avaliação é acessado por meio de uma propriedade somente leitura chamada IsAdmin.

    4. Adicione if (!IsAdmin) return Forbid(); ao início de ambos os métodos OnPost e OnPostDelete:

      public IActionResult OnPost()
      {
          if (!IsAdmin) return Forbid();
          if (!ModelState.IsValid)
          {
              return Page();
          }
          PizzaService.Add(NewPizza);
          return RedirectToAction("Get");
      }
      
      public IActionResult OnPostDelete(int id)
      {
          if (!IsAdmin) return Forbid();
          PizzaService.Delete(id);
          return RedirectToAction("Get");
      }
      

      Você vai ocultar os elementos da interface do usuário de criação/exclusão para não administradores na próxima etapa. Isso não impede que um adversário com uma ferramenta como HttpRepl ou curl acessem esses pontos de extremidade diretamente. Adicionar essa verificação garante que, se isso for tentado, um código de status HTTP 403 será retornado.

  2. Em Pages/Pizza.cshtml, adicione verificações para ocultar elementos da interface do usuário do administrador de não administradores:

    Ocultar formulário Nova pizza

    <h1>Pizza List 🍕</h1>
    @if (Model.IsAdmin)
    {
    <form method="post" class="card p-3">
        <div class="row">
            <div asp-validation-summary="All"></div>
        </div>
        <div class="form-group mb-0 align-middle">
            <label asp-for="NewPizza.Name">Name</label>
            <input type="text" asp-for="NewPizza.Name" class="mr-5">
            <label asp-for="NewPizza.Size">Size</label>
            <select asp-for="NewPizza.Size" asp-items="Html.GetEnumSelectList<PizzaSize>()" class="mr-5"></select>
            <label asp-for="NewPizza.Price"></label>
            <input asp-for="NewPizza.Price" class="mr-5" />
            <label asp-for="NewPizza.IsGlutenFree">Gluten Free</label>
            <input type="checkbox" asp-for="NewPizza.IsGlutenFree" class="mr-5">
            <button class="btn btn-primary">Add</button>
        </div>
    </form>
    }
    

    Ocultar botão Excluir pizza

    <table class="table mt-5">
        <thead>
            <tr>
                <th scope="col">Name</th>
                <th scope="col">Price</th>
                <th scope="col">Size</th>
                <th scope="col">Gluten Free</th>
                @if (Model.IsAdmin)
                {
                <th scope="col">Delete</th>
                }
            </tr>
        </thead>
        @foreach (var pizza in Model.pizzas)
        {
            <tr>
                <td>@pizza.Name</td>
                <td>@($"{pizza.Price:C}")</td>
                <td>@pizza.Size</td>
                <td>@Model.GlutenFreeText(pizza)</td>
                @if (Model.IsAdmin)
                {
                <td>
                    <form method="post" asp-page-handler="Delete" asp-route-id="@pizza.Id">
                        <button class="btn btn-danger">Delete</button>
                    </form>
                </td>
                }
            </tr>
        }
    </table>
    

    As alterações anteriores fazem com que os elementos de interface do usuário que devem ser acessíveis somente aos administradores sejam renderizados apenas quando o usuário autenticado é um administrador.

Aplicar uma política de autorização

Há mais uma coisa que você deve bloquear. Há uma página que deve ser acessível somente para administradores, convenientemente denominada Pages/AdminsOnly.cshtml. Vamos criar uma política para verificar a declaração IsAdmin=True.

  1. Em Program.cs, faça as seguintes alterações:

    1. Incorpore o seguinte código realçado:

      // Add services to the container.
      builder.Services.AddRazorPages();
      builder.Services.AddTransient<IEmailSender, EmailSender>();
      builder.Services.AddSingleton(new QRCodeService(new QRCodeGenerator()));
      builder.Services.AddAuthorization(options =>
          options.AddPolicy("Admin", policy =>
              policy.RequireAuthenticatedUser()
                  .RequireClaim("IsAdmin", bool.TrueString)));
      
      var app = builder.Build();
      

      O código anterior define uma política de autorização chamada Admin. A política exige que o usuário seja autenticado e tenha uma declaração IsAdmin definida como True.

    2. Modifique a chamada para AddRazorPages da seguinte maneira:

      builder.Services.AddRazorPages(options =>
          options.Conventions.AuthorizePage("/AdminsOnly", "Admin"));
      

      A chamada de método AuthorizePage protege a rota da Página Razor /AdminsOnly aplicando a política Admin. Os usuários autenticados que não atenderem aos requisitos da política verão uma mensagem Acesso negado.

      Dica

      Como alternativa, você poderia ter modificado AdminsOnly.cshtml.cs. Nesse caso, você adicionaria [Authorize(Policy = "Admin")] como um atributo na classe AdminsOnlyModel. Uma vantagem para a abordagem AuthorizePage mostrada acima é que a Página Razor que está sendo protegida não exige modificações. Em vez disso, o aspecto de autorização é gerenciado em Program.cs.

  2. Em Pages/Shared/_Layout.cshtml, incorpore as seguintes alterações:

    <ul class="navbar-nav flex-grow-1">
        <li class="nav-item">
            <a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
        </li>
        <li class="nav-item">
            <a class="nav-link text-dark" asp-area="" asp-page="/Pizza">Pizza List</a>
        </li>
        <li class="nav-item">
            <a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>
        </li>
        @if (Context.User.HasClaim("IsAdmin", bool.TrueString))
        {
        <li class="nav-item">
            <a class="nav-link text-dark" asp-area="" asp-page="/AdminsOnly">Admins</a>
        </li>
        }
    </ul>
    

    A alteração anterior oculta condicionalmente o link Administrador no cabeçalho se o usuário não é um administrador. Ele usa a propriedade Context da classe RazorPage para acessar o HttpContext que contém as informações sobre o usuário autenticado.

Adicionar a declaração IsAdmin a um usuário

Para determinar quais usuários devem receber a declaração IsAdmin=True, seu aplicativo dependerá de um endereço de email confirmado para identificar o administrador.

  1. Em appsettings.json, adicione a propriedade realçada:

    {
      "AdminEmail" : "admin@contosopizza.com",
      "Logging": {
    

    Esse é o endereço de email confirmado que recebe a declaração atribuída.

  2. Em Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs, faça as seguintes alterações:

    1. Incorpore o seguinte código realçado:

      public class ConfirmEmailModel : PageModel
      {
          private readonly UserManager<RazorPagesPizzaUser> _userManager;
          private readonly IConfiguration Configuration;
      
          public ConfirmEmailModel(UserManager<RazorPagesPizzaUser> userManager,
                                      IConfiguration configuration)
          {
              _userManager = userManager;
              Configuration = configuration;
          }
      
      

      A alteração anterior modifica o construtor para receber um IConfiguration do contêiner IoC. O IConfiguration contém valores de appsettings.json e é atribuído a uma propriedade somente leitura chamada Configuration.

    2. Aplique as alterações realçadas ao método OnGetAsync:

      public async Task<IActionResult> OnGetAsync(string userId, string code)
      {
          if (userId == null || code == null)
          {
              return RedirectToPage("/Index");
          }
      
          var user = await _userManager.FindByIdAsync(userId);
          if (user == null)
          {
              return NotFound($"Unable to load user with ID '{userId}'.");
          }
      
          code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
          var result = await _userManager.ConfirmEmailAsync(user, code);
          StatusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
      
          var adminEmail = Configuration["AdminEmail"] ?? string.Empty;
          if(result.Succeeded)
          {
              var isAdmin = string.Compare(user.Email, adminEmail, true) == 0 ? true : false;
              await _userManager.AddClaimAsync(user, 
                  new Claim("IsAdmin", isAdmin.ToString()));
          }
      
          return Page();
      }
      

      No código anterior:

      • A cadeia de caracteres AdminEmail é lida da propriedade Configuration e atribuída a adminEmail.
      • O operador de avaliação de nulo ?? é usado para garantir que adminEmail seja definido como string.Empty se não houver nenhum valor correspondente em appsettings.json.
      • Se o email do usuário for confirmado com êxito:
        • O endereço do usuário é comparado a adminEmail. string.Compare() é usado para comparação sem diferenciação de maiúsculas de minúsculas.
        • O método AddClaimAsync da classe UserManager é invocado para salvar uma declaração IsAdmin na tabela AspNetUserClaims.
    3. Adicione o código a seguir no início do arquivo. Ele resolve as referências de classe Claim no método OnGetAsync:

      using System.Security.Claims;
      

Testar a declaração de administrador

Vamos fazer um último teste para verificar a nova funcionalidade de administrador.

  1. Verifique se você salvou todas as alterações.

  2. Execute o aplicativo com dotnet run.

  3. Navegue até o aplicativo e entre com um usuário existente, se você ainda não estiver conectado. Selecione Lista de Pizzas no cabeçalho. Observe que o usuário não vê elementos da interface do usuário para excluir ou criar pizzas.

  4. Não há nenhum link de Administradores no cabeçalho. Na barra de endereços do navegador, navegue diretamente até a página AdminsOnly. Substitua /Pizza na URL por /AdminsOnly.

    É proibido que o usuário navegue até a página. Uma mensagem Acesso negado é exibida.

  5. Selecione Logoff.

  6. Registre um novo usuário com o endereço admin@contosopizza.com.

  7. Como antes, confirme o endereço de email do novo usuário e entre.

  8. Depois de entrar com o novo usuário administrativo, clique no link Lista de Pizzas no cabeçalho.

    O usuário administrativo pode criar e excluir pizzas.

  9. Clique no link Administradores no cabeçalho.

    A página AdminsOnly aparece.

Examinar a tabela AspNetUserClaims

Usando a extensão SQL Server no VS Code, execute a seguinte consulta:

SELECT u.Email, c.ClaimType, c.ClaimValue
FROM dbo.AspNetUserClaims AS c
    INNER JOIN dbo.AspNetUsers AS u
    ON c.UserId = u.Id

Uma guia com resultados semelhantes aos seguintes aparece:

Email ClaimType ClaimValue
admin@contosopizza.com IsAdmin True

A declaração IsAdmin é armazenada como um par chave-valor na tabela AspNetUserClaims. O registro AspNetUserClaims é associado ao registro de usuário na tabela AspNetUsers.

Resumo

Nesta unidade, você modificou o aplicativo para armazenar declarações e aplicar políticas para acesso condicional.