Exercise - Use claims with policy-based authorization
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.
In Pages/Pizza.cshtml.cs, apply the following changes:
Add an
[Authorize]
attribute to thePizzaModel
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.
Resolve the reference to
Authorize
by adding the following line to theusing
directives at the top of the file:using Microsoft.AspNetCore.Authorization;
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 ofTrue
. The result of this evaluation is accessed via a read-only property namedIsAdmin
.Add
if (!IsAdmin) return Forbid();
to the beginning of both theOnPost
andOnPostDelete
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.
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.
In Program.cs, make the following changes:
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 anIsAdmin
claim set toTrue
.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 theAdmin
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 theAdminsOnlyModel
class. An advantage to theAuthorizePage
approach shown above is that the Razor Page being secured requires no modifications. The authorization aspect is instead managed in Program.cs.
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.
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.
In appsettings.json, add the highlighted property:
{ "AdminEmail" : "admin@contosopizza.com", "Logging": {
This is the confirmed email address that gets the claim assigned.
In Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs, make the following changes:
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. TheIConfiguration
contains values from appsettings.json, and is assigned to a read-only property namedConfiguration
.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 theConfiguration
property and assigned toadminEmail
. - The null-coalescing operator
??
is used to ensureadminEmail
is set tostring.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'sAddClaimAsync
method is invoked to save anIsAdmin
claim in theAspNetUserClaims
table.
- The user's address is compared to
- The
Add the following code to the top of the file. It resolves the
Claim
class references in theOnGetAsync
method:using System.Security.Claims;
Test admin claim
Let's do one last test to verify the new administrator functionality.
Make sure you've saved all your changes.
Run the app with
dotnet run
.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.
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.
Select Logout.
Register a new user with the address
admin@contosopizza.com
.As before, confirm the new user's email address and sign in.
Once signed in with the new administrative user, select the Pizza List link in the header.
The administrative user can create and delete pizzas.
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:
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.