Ejercicio: uso de notificaciones con autorización basada en directivas

Completado

En la unidad anterior, ha aprendido la diferencia entre autenticación y autorización. También ha aprendido cómo las directivas usan las notificaciones para la autorización. En esta unidad, se usa Identity para almacenar notificaciones y aplicar directivas para el acceso condicional.

Protección de la lista de pizzas

Ha recibido un nuevo requisito de que la página de lista de pizzas solo sea visible para los usuarios autenticados. Además, solo los administradores tendrán permiso para crear y eliminar pizzas. Lo hará a continuación.

  1. En Pages/Pizza.cshtml.cs, aplique los cambios siguientes:

    1. Agregue un atributo [Authorize] a la clase PizzaModel.

      [Authorize]
      public class PizzaModel : PageModel
      

      El atributo describe los requisitos de autorización de usuarios para la página. En este caso, no hay ningún requisito aparte de que el usuario se tenga que autenticar. No se permite que los usuarios anónimos vean la página y se les redirige a la página de inicio de sesión.

    2. Resuelva la referencia a Authorize mediante la adición de la línea siguiente a las directivas using en la parte superior del archivo:

      using Microsoft.AspNetCore.Authorization;
      
    3. Agregue la siguiente propiedad a la clase PizzaModel:

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

      El código anterior determina si el usuario autenticado tiene una notificación IsAdmin con un valor de True. Se puede acceder al resultado de esta evaluación a través de una propiedad de solo lectura llamada IsAdmin.

    4. Agregue if (!IsAdmin) return Forbid(); al principio de los dos métodos OnPost y 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");
      }
      

      En el paso siguiente, ocultará los elementos de creación o eliminación de la interfaz de usuario para los usuarios que no sean administradores. Esto no impide que un adversario con una herramienta como HttpRepl o curl acceda directamente a estos puntos de conexión. La adición de esta comprobación garantiza que, si se intenta, se devuelve un código de estado HTTP 403.

  2. En Pages/Pizza.cshtml, agregue comprobaciones para ocultar los elementos de la interfaz de usuario de administrador a los no administradores:

    Ocultar el formulario Nueva 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 el botón Eliminar 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>
    

    Los cambios anteriores hacen que los elementos de la interfaz de usuario que solo deben ser accesibles para los administradores solo se representen cuando el usuario autenticado es un administrador.

Aplicación de una directiva de autorización

Hay una cosa más que debería hacer. Hay una página que solo debe ser accesible para los administradores, con el nombre Pages/AdminsOnly.cshtml. Ahora se creará una directiva para comprobar la notificación IsAdmin=True.

  1. En Program.cs, realice los cambios siguientes:

    1. Incorpore el código resaltado siguiente:

      // 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();
      

      En el código anterior se define una directiva de autorización llamada Admin. La directiva requiere que el usuario se autentique y tenga una notificación IsAdmin establecida en True.

    2. Modifique la llamada a AddRazorPages como se indica a continuación:

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

      La llamada de método AuthorizePage protege la ruta de la página de Razor /AdminsOnly mediante la aplicación de la directiva Admin. A los usuarios autenticados que no cumplan los requisitos de la directiva se les aparece un mensaje de Acceso denegado.

      Sugerencia

      Como alternativa, podría haber modificado AdminsOnly.cshtml.cs. En ese caso, tendría que agregar [Authorize(Policy = "Admin")] como atributo en la clase AdminsOnlyModel. Una ventaja del enfoque de AuthorizePage es que la página de Razor que se protege no necesita ninguna modificación. En su lugar, el aspecto de la autorización se administra en Program.cs.

  2. En Pages/Shared/_Layout.cshtml, incorpore los cambios siguientes:

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

    El cambio anterior oculta condicionalmente el vínculo Admin en el encabezado si el usuario no es un administrador.

Adición de la notificación IsAdmin a un usuario

Para determinar qué usuarios deben obtener la notificación IsAdmin=True, la aplicación se basará en una dirección de correo electrónico confirmada para identificar al administrador.

  1. En appsettings.json, agregue la propiedad resaltada:

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

    Esta es la dirección de correo electrónico confirmada que obtiene la notificación asignada.

  2. En Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs, realice los cambios siguientes:

    1. Incorpore el código resaltado siguiente:

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

      El cambio anterior modifica el constructor para recibir un elemento IConfiguration del contenedor de IoC. IConfiguration contiene valores de appsettings.json y se asigna a una propiedad de solo lectura denominada Configuration.

    2. Aplique los cambios resaltados en el 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();
      }
      

      En el código anterior:

      • La cadena AdminEmail se lee de la propiedad Configuration y se asigna a adminEmail.
      • El operador de fusión de NULL ?? se usa para asegurarse de que adminEmail se establezca en string.Empty si no hay ningún valor correspondiente en appsettings.json.
      • Si el correo electrónico del usuario se confirma correctamente:
        • La dirección del usuario se compara con adminEmail. string.Compare() se usa para la comparación sin distinción de mayúsculas y minúsculas.
        • Se invoca el método AddClaimAsync de la clase UserManager para guardar una notificación IsAdmin en la tabla AspNetUserClaims.
    3. Agregue el código siguiente en la parte superior del archivo. Resuelve las referencias de clase Claim en el método OnGetAsync:

      using System.Security.Claims;
      

Prueba de notificación de administrador

Ahora se realizará una última prueba para comprobar la nueva funcionalidad de administrador.

  1. Asegúrese de que ha guardado todos los cambios.

  2. Ejecute la aplicación con dotnet run.

  3. Vaya a la aplicación y, si aún no ha iniciado sesión, hágalo con un usuario existente. Seleccione Lista de pizzas en el encabezado. Observe que al usuario no se le muestran elementos de la interfaz de usuario para eliminar o crear pizzas.

  4. No hay ningún vínculo Admins en el encabezado. En la barra de direcciones del explorador, vaya directamente a la página AdminsOnly. Reemplace /Pizza en la dirección URL por /AdminsOnly.

    Se prohíbe al usuario navegar a la página. Se muestra un mensaje de Acceso denegado.

  5. Seleccione Cerrar sesión.

  6. Registre un nuevo usuario con la dirección admin@contosopizza.com.

  7. Como antes, confirme la dirección de correo electrónico del nuevo usuario e inicie sesión.

  8. Una vez que haya iniciado sesión con el nuevo usuario administrativo, seleccione el vínculo Lista de pizzas en el encabezado.

    El usuario administrativo puede crear y eliminar pizzas.

  9. Seleccione el vínculo Administradores en el encabezado.

    Aparecerá la página AdminsOnly.

Revisión de la tabla AspNetUserClaims

Con la extensión SQL Server en VS Code, ejecute la siguiente consulta:

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

Aparece una pestaña con resultados similares a los siguientes:

Correo electrónico ClaimType ClaimValue
admin@contosopizza.com IsAdmin True

La notificación IsAdmin se almacena como un par clave-valor en la tabla AspNetUserClaims. El registro AspNetUserClaims está asociado con el registro de usuario en la tabla AspNetUsers.

Resumen

En esta unidad, ha modificado la aplicación para almacenar notificaciones y aplicar directivas para el acceso condicional.