Exercise - Use claims with policy-based authorization

Completed

In the previous unit, you learned the difference between authentication and authorization. You also learned how claims are used by policies for authorization. In this unit, you use Identity to store claims and apply policies for conditional access.

Secure the pizza list

You've received a new requirement that the Pizza List page should be visible only to authenticated users. Additionally, only administrators are allowed to create and delete pizzas. Let's lock it down.

  1. In Pages/Pizza.cshtml.cs, apply the following changes:

    1. Add an [Authorize] attribute to the PizzaModel class.

      [Authorize]
      public class PizzaModel : PageModel
      

      The attribute describes user authorization requirements for the page. In this case, there are no requirements beyond the user being authenticated. Anonymous users aren't allowed to view the page and are redirected to the sign-in page.

    2. Resolve the reference to Authorize by adding the following line to the using directives at the top of the file:

      using Microsoft.AspNetCore.Authorization;
      
    3. Add the following property to the PizzaModel class:

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

      The preceding code determines whether the authenticated user has an IsAdmin claim with a value of True. The code gets information about the authenticated user from the HttpContext in the parent PageModel class. The result of this evaluation is accessed via a read-only property named IsAdmin.

    4. Add if (!IsAdmin) return Forbid(); to the beginning of both the OnPost and OnPostDelete methods:

      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");
      }
      

      You're going to hide the creation/deletion UI elements for non-administrators in the next step. That doesn't prevent an adversary with a tool like HttpRepl or curl from accessing these endpoints directly. Adding this check ensures that if this is attempted, an HTTP 403 status code is returned.

  2. In Pages/Pizza.cshtml, add checks to hide administrator UI elements from non-administrators:

    Hide New pizza form

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

    Hide Delete pizza button

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

    The preceding changes cause UI elements that should be accessible only to administrators to be rendered only when the authenticated user is an administrator.

Apply an authorization policy

There's one more thing you should lock down. There's a page that should be accessible only to administrators, conveniently named Pages/AdminsOnly.cshtml. Let's create a policy to check the IsAdmin=True claim.

  1. In Program.cs, make the following changes:

    1. Incorporate the following highlighted code:

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

      The preceding code defines an authorization policy named Admin. The policy requires that the user is authenticated and has an IsAdmin claim set to True.

    2. Modify the call to AddRazorPages as follows:

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

      The AuthorizePage method call secures the /AdminsOnly Razor Page route by applying the Admin policy. Authenticated users who don't satisfy the policy requirements are presented an Access denied message.

      Tip

      Alternatively, you could have instead modified AdminsOnly.cshtml.cs. In that case, you would add [Authorize(Policy = "Admin")] as an attribute on the AdminsOnlyModel class. An advantage to the AuthorizePage approach shown above is that the Razor Page being secured requires no modifications. The authorization aspect is instead managed in Program.cs.

  2. In Pages/Shared/_Layout.cshtml, incorporate the following changes:

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

    The preceding change conditionally hides the Admin link in the header if the user isn't an administrator. It uses the Context property of the RazorPage class to access the HttpContext containing the information about the authenticated user.

Add the IsAdmin claim to a user

In order to determine which users should get the IsAdmin=True claim, your app is going to rely on a confirmed email address to identify the administrator.

  1. In appsettings.json, add the highlighted property:

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

    This is the confirmed email address that gets the claim assigned.

  2. In Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs, make the following changes:

    1. Incorporate the following highlighted code:

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

      The preceding change modifies the constructor to receive an IConfiguration from the IoC container. The IConfiguration contains values from appsettings.json, and is assigned to a read-only property named Configuration.

    2. Apply the highlighted changes to the OnGetAsync method:

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

      In the preceding code:

      • The AdminEmail string is read from the Configuration property and assigned to adminEmail.
      • The null-coalescing operator ?? is used to ensure adminEmail is set to string.Empty if there's no corresponding value in appsettings.json.
      • If the user's email is successfully confirmed:
        • The user's address is compared to adminEmail. string.Compare() is used for case-insensitive comparison.
        • The UserManager class's AddClaimAsync method is invoked to save an IsAdmin claim in the AspNetUserClaims table.
    3. Add the following code to the top of the file. It resolves the Claim class references in the OnGetAsync method:

      using System.Security.Claims;
      

Test admin claim

Let's do one last test to verify the new administrator functionality.

  1. Make sure you've saved all your changes.

  2. Run the app with dotnet run.

  3. Navigate to your app and sign in with an existing user, if you're not already signed in. Select Pizza List from the header. Notice the user isn't presented UI elements to delete or create pizzas.

  4. There's no Admins link in the header. In the browser's address bar, navigate directly to the AdminsOnly page. Replace /Pizza in the URL with /AdminsOnly.

    The user is forbidden from navigating to the page. An Access denied message is displayed.

  5. Select Logout.

  6. Register a new user with the address admin@contosopizza.com.

  7. As before, confirm the new user's email address and sign in.

  8. Once signed in with the new administrative user, select the Pizza List link in the header.

    The administrative user can create and delete pizzas.

  9. Select the Admins link in the header.

    The AdminsOnly page appears.

Examine the AspNetUserClaims table

Using the SQL Server extension in VS Code, run the following query:

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

A tab with results similar to the following appears:

Email ClaimType ClaimValue
admin@contosopizza.com IsAdmin True

The IsAdmin claim is stored as a key-value pair in the AspNetUserClaims table. The AspNetUserClaims record is associated with the user record in the AspNetUsers table.

Summary

In this unit, you modified the app to store claims and apply policies for conditional access.