📓 3.5.0.9 Adding Authorization and Associating Users with Items
We now have a working user login and registration system, but it doesn't actually have any kind of impact on our application. Our users can do all CRUD regardless of whether they are signed in or not. We'll make our authentication more impactful by limiting what a user can do with authorization.
Authorization
Authorization is the process of managing what a user is allowed to do. We'll update our To Do List application so that only logged in users will be able to see their own list of items. This is similar to many real-world applications such as email or blog sites where a signed-in user has access to their own content. As we'll see, adding authorization is very simple, but associating items with a user takes more refactoring.
Note that we will not modify categories or tags in this lesson and that by the end of this lesson, authorization will only be applied to creating and viewing a user's items. However, we encourage you to practice more with authorization by adding it to categories and tags on your own.
How We'll Implement Authorization
To implement authorization, we'll be using tools from the Microsoft.AspNetCore.Authorization
namespace. These authorization tools work in conjunction with Identity, which provides authentication.
We'll specifically implement simple authorization, though the MS Docs have guidance on other ways to authorize users. Check out the docs on authorization for more information!
Adding Authorization
To add authorization to the ItemsController
, we'll use the [Authorize]
attribute along with a using
statement:
...
using Microsoft.AspNetCore.Authorization;
namespace ToDoList.Controllers
{
[Authorize] // New line
public class ItemsController : Controller
{
...
}
}
We add a using directive for the Microsoft.AspNetCore.Authorization
namespace so that we can use the [Authorize]
attribute.
Notice that we include an [Authorize]
attribute on ItemsController
:
...
[Authorize]
public class ItemsController : Controller
...
This allows access to the ItemsController
only if a user is logged in. We'll add this attribute to a controller whenever we want to limit its access to signed-in users only.
And with this simple addition, we've added authorization to our application!
Other Authorization
Adding [Authorize]
to a controller is just one application of authorization. We can also add the [Authorize]
attribute to individual controller actions.
When we add [Authorize]
to the ItemsController
, the entirety of the controller is shielded from unauthorized users. We can negate this by including an [AllowAnonymous]
attribute above any specific methods that we want unauthorized users to have access to. For example, we could put [AllowAnonymous]
above the Index
route, if we want users to be able to see a list of items, but require authorization before they view details.
Alternatively, we could avoid putting the [Authorize]
attribute on the entire class, and instead only place it on specific methods we want guarded. For example, if we wanted unauthorized users to view many routes in a controller, but protect your Create
, Update
, and Delete
routes, you could simply [Authorize]
those specific methods.
For the purposes of learning basic authorization tools, we'll continue with the [Authorize]
route on the entire controller, as shown. As noted earlier, we're implementing simple authorization. To learn about other ways to add authorization to our ASP.NET Core, visit the docs on authorization.
Associating Users with Items
With authorization now added to our ItemsController
, you need to be signed in to visit any route for items. However once we are signed in, we can see any user's items. To make our authorization more meaningful, we'll update our To Do List app so that each item that is created is associated with the logged in user, and the only items visible to a user is their own.
Updating the Item
Model
To achieve our goal, the first thing we'll do is create an association between Identity's ApplicationUser
and our Item
class. This will enable us to associate items with a specific user, and fetch and display only the items that belong to that user.
Let's add a new User
property to our Item.cs
model, which should then look like this:
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace ToDoList.Models
{
public class Item
{
public int ItemId { get; set; }
[Required(ErrorMessage = "The item's description can't be empty!")]
public string Description { get; set; }
[Range(1, int.MaxValue, ErrorMessage = "You must add your item to a category. Have you created a category yet?")]
public int CategoryId { get; set; }
public Category Category { get; set; }
public List<ItemTag> JoinEntities { get;}
public ApplicationUser User { get; set; } // New line!
}
}
Since we've updated our entity model, we need to create a new migration and update the database. In our ToDolist/
directory, let's run the following command:
$ dotnet ef migrations add AddUserToItem
This command will add a new migration and its corresponding designer file to our Migrations/
directory while updating the model snapshot for our ToDoListContext
.
Now, let's update the database:
$ dotnet ef database update
Updating the ItemsController
Next, let's update our ItemsController
. We'll start by adding the following using
statements:
...
//new code
using Microsoft.AspNetCore.Identity;
using System.Threading.Tasks;
using System.Security.Claims;
...
- We'll need
Microsoft.AspNetCore.Identity
so our controller can use theUserManager
and other tools from Identity to get users from the database. System.Threading.Tasks
is necessary to call async methods. The async methods we'll use are from theUserManager
class.System.Security.Claims
is necessary for using claim based authorization. A claim is a form of user identification. It states who a user is, not what the user can actually do. While the user identification itself doesn't authorize a user to do anything, it is necessary for us to be able to identify a user (through a claim) in order to get only the items that are associated with that user.
Next, we'll add Identity's UserManager
class to our controller so that we can access tools to get us data about the signed-in user. We'll add a new property and update our constructor like so:
...
namespace ToDoList.Controllers
{
[Authorize]
public class ItemsController : Controller
{
private readonly ToDoListContext _db;
private readonly UserManager<ApplicationUser> _userManager; // New line
// Updated constructor
public ItemsController(UserManager<ApplicationUser> userManager, ToDoListContext db)
{
_userManager = userManager;
_db = db;
}
...
Let's break this code down into smaller pieces.
First, let's take a look at our readonly
property and our constructor:
...
private readonly UserManager<ApplicationUser> _userManager;
public ItemsController(UserManager<ApplicationUser> userManager, ToDoListContext database)
{
_userManager = userManager;
_db = database;
}
...
This code should look more familiar because our AccountController
has the exact same code. We need an instance of UserManager
in order to access the tools that get us data about the signed-in user. We also include a constructor to instantiate private readonly
instances of the database and the UserManager
.
Updating the Index()
Action
Let's update our Index()
method to determine who the currently signed-in user is, and to grab only their items from the database. Here's what our Index()
action should now look like:
...
public async Task<ActionResult> Index()
{
string userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
ApplicationUser currentUser = await _userManager.FindByIdAsync(userId);
List<Item> userItems = _db.Items
.Where(entry => entry.User.Id == currentUser.Id)
.Include(item => item.Category)
.ToList();
return View(userItems);
}
...
There's a lot to unpack here.
We start by using the
async
modifier because this action will run asynchronously. Because the action is asynchronous, it also returns aTask
containing an action result.Then we locate the unique identifier for the currently-logged-in user and assign it the variable name
userId
. Let's go over the new logic in the following line of code:
string userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
User
is a property of ourItemsController
(which extends fromController
), just likeModelState
is, and we can call on this property anywhere within our controller.User
contains information about the currently signed-in user.FindFirst()
is a method that locates the first user record that meets the provided criteria.- This method takes
ClaimTypes.NameIdentifier
as an argument. This is why we need ausing
directive forSystem.Security.Claims
. We specifyClaimTypes.NameIdentifier
to locate the unique ID associated with the currently signed in user account.NameIdentifier
is a property that refers to an Entity's unique ID. See the ClaimTypes documentation for more information on this class. - Finally, we include the
?
operator after the linethis.User.FindFirst(ClaimTypes.NameIdentifier)
. This is called an existential operator. It states that we should only call the property to the right of the?
if the method to the left of the?
doesn't returnnull
.- Essentially, the code states that if
this.User.FindFirst(ClaimTypes.NameIdentifier)
returnsnull
, don't call the property to the right of the existential operator. However, if it doesn't returnnull
, it retrievesValue
property. - In other words, if we successfully locate the
NameIdentifier
of the current user, we'll callValue
to retrieve the actual unique identifier value.
- Essentially, the code states that if
Once we have the userId
value, we're ready to call our async method:
ApplicationUser currentUser = await _userManager.FindByIdAsync(userId);
- First we call Identity's
UserManager
service that we've injected into this controller. - We call the
FindByIdAsync()
method, which, as its name suggests, is a built-in Identity method used to find a user's account by their unique ID. - We provide the
userId
we just located as an argument toFindByIdAsync()
. - Thanks to the handy
Async
suffix in this method's name, we know it runs asynchronously. We include theawait
keyword so the code will wait for Identity to locate the correct user before moving on.
Finally, we create a variable to store a collection containing only the Item
s that are associated with the currently-logged-in user's unique Id
property:
...
List<Item> userItems = _db.Items
.Where(entry => entry.User.Id == currentUser.Id)
.Include(item => item.Category)
.ToList();
return View(userItems);
...
We use the Where()
method, which is a LINQ method we can use to filter a collection in a way that echoes the logic of the SQL WHERE
clause. We can use Where()
to make many different kinds of queries, as the method accepts an expression to filter our results.
In this case, we're simply asking EF Core to find items in the database where the user id associated with the item is the same id as the id that belongs to the currentUser
. This ensures the signed-in user only sees their own items in the view.
Updating Create()
POST Action
Let's now edit our Create()
POST action. Make sure you update the post method and not the get method.
[HttpPost]
public async Task<ActionResult> Create(Item item, int CategoryId)
{
if (!ModelState.IsValid)
{
ViewBag.CategoryId = new SelectList(_db.Categories, "CategoryId", "Name");
return View(item);
}
else
{
string userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
ApplicationUser currentUser = await _userManager.FindByIdAsync(userId);
item.User = currentUser;
_db.Items.Add(item);
_db.SaveChanges();
return RedirectToAction("Index");
}
}
Everything with the model validation is the same. We've only updated the else
branch of the Create()
POST method.
The first two lines within the else
branch are exactly the same as the first two lines of our Index()
action: we start by finding the value of the current user.
Once we have the currentUser
object, we associate it with the Item
's User
property. This makes the association so that an Item
belongs to a User
.
Finally, we add the item to the database and save it, just as we did before.
Updating the Item
Views for Authorization
Well... we actually don't have to update our views at all. All of the associating between items and the logged in user happens in the ItemsController
.
We can, however, make a small update to our Items/Index.cshtml
. Let's address the signed in user by name. To do this, we'll update our introductory header like so:
<h1>Items for @User.Identity.Name</h1>
And here's how the entire file will look:
@{
Layout = "_Layout";
}
@using ToDoList.Models;
<h1>Items for @User.Identity.Name</h1>
@if (@Model.Count == 0)
{
<h3>No items have been added yet!</h3>
}
else
{
<ul>
@foreach (Item item in Model)
{
<li>@Html.ActionLink($"{item.Description}", "Details", new { id = item.ItemId }) | @item.Category.Name</li>
}
</ul>
}
<p>@Html.ActionLink("Add new item", "Create")</p>
<p>@Html.ActionLink("Home", "Index", "Home")</p>
Because we've added the [Authorize]
attribute to our ItemsController
, we know that only logged in users will be able to view this page, so we can safely update our header to directly address the user's Name
.
So what happens if an unauthorized user (who is not signed in) tries to access our item's Index.cshtml
? Identity will automatically redirect them to the login page, just like in the following image:
Adjusting Our Splash Page
We're almost done with our refactor. The one thing we have not addressed is what to do about our splash page. Right now it lists all items saved in the items database (along with all categories). Now that only logged in users can view their own items, we need to update the HomeController
to get the logged in user's items, and our view to display a message asking visitors to log in if they want to see their own items.
Let's start with the HomeController
first. Here's the updated controller:
using Microsoft.AspNetCore.Mvc;
using ToDoList.Models;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Identity;
using System.Threading.Tasks;
using System.Security.Claims;
namespace ToDoList.Controllers
{
public class HomeController : Controller
{
private readonly ToDoListContext _db;
private readonly UserManager<ApplicationUser> _userManager;
public HomeController(UserManager<ApplicationUser> userManager, ToDoListContext db)
{
_userManager = userManager;
_db = db;
}
[HttpGet("/")]
public async Task<ActionResult> Index()
{
Category[] cats = _db.Categories.ToArray();
Dictionary<string,object[]> model = new Dictionary<string, object[]>();
model.Add("categories", cats);
string userId = this.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
ApplicationUser currentUser = await _userManager.FindByIdAsync(userId);
if (currentUser != null)
{
Item[] items = _db.Items
.Where(entry => entry.User.Id == currentUser.Id)
.ToArray();
model.Add("items", items);
}
return View(model);
}
}
}
First notice that we add new using directives to access the tools we need to get the current user:
using Microsoft.AspNetCore.Identity;
using System.Threading.Tasks;
using System.Security.Claims;
Next, notice that we add Identity's UserManager
to our controller as a property (_userManager
) and by updating our controller's constructor to access the UserManager
service through dependency injection.
public class HomeController : Controller
{
...
private readonly UserManager<ApplicationUser> _userManager;
public HomeController(UserManager<ApplicationUser> userManager, ToDoListContext db)
{
_userManager = userManager;
_db = db;
}
...
}
...
Next, notice the changes in the Index()
action. Here is the Index()
action one more time:
[HttpGet("/")]
public async Task<ActionResult> Index()
{
// Category logic
Category[] cats = _db.Categories.ToArray();
Dictionary<string,object[]> model = new Dictionary<string, object[]>();
model.Add("categories", cats);
// Item logic
string userId = this.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
ApplicationUser currentUser = await _userManager.FindByIdAsync(userId);
if (currentUser != null)
{
Item[] items = _db.Items
.Where(entry => entry.User.Id == currentUser.Id)
.ToArray();
model.Add("items", items);
}
return View(model);
}
With this update, we've reorganized the existing code, along with adding new code. Now instead of turning the entire items database into an array, we only do so if there's a currently logged in user. Note that this is just one way of many for setting up this logic. You are welcome to reimagine this logic or the data types used as you see fit. Now let's break down this code a bit more.
First, we deal with displaying our categories. Here, we also create the dictionary model
that will hold our category and item data.
Then, we go through the process of getting the currentUser
object. This is the same code that we used earlier in our ItemsController
.
Next, we create a branch that checks if the currentUser
is not null
:
- If
currentUser
isnull
, that means there's no signed in user and we simply return the view with no items. - If
currentUser
has a value (is notnull
), we go through the process of fetching the items belonging to that user and adding them to our view'smodel
.
Now let's see how we'll update the view to work with the new model we've created in our controller action. Take note that our Home/Index.cshtml
view will only have an items
key in our dictionary model
if there's a logged in user. That means we need to make sure that our view ensures there's an authenticated user before displaying items.
Here's the updated code for the entire Home/Index.cshtml
:
@{
Layout = "_Layout";
}
@using ToDoList.Models;
<h1>Welcome to the To Do List!</h1>
<hr />
<h4>Categories</h4>
@if (Model["categories"].Length == 0)
{
<p>No categories have been added yet!</p>
}
<ul>
@foreach (Category cat in Model["categories"])
{
<li>@Html.ActionLink(@cat.Name, "Details", "Categories", new { id = @cat.CategoryId})</li>
}
</ul>
@if (User.Identity.IsAuthenticated)
{
<h4>Items for @User.Identity.Name</h4>
@if (Model["items"].Length == 0)
{
<p>No items have been added yet!</p>
}
<ul>
@foreach (Item item in Model["items"])
{
<li>@Html.ActionLink(@item.Description, "Details", "Items", new { id = @item.ItemId})</li>
}
</ul>
}
else
{
<h4>Items</h4>
<p>Please @Html.ActionLink("log in", "LogIn", "Account") to view or manage your items.</p>
}
<hr />
<p>@Html.ActionLink("Manage items", "Index", "Items")</p>
<p>@Html.ActionLink("Manage categories", "Index", "Categories")</p>
<p>@Html.ActionLink("Manage tags", "Index", "Tags")</p>
<p>@Html.ActionLink("Create or manage an account", "Index", "Account")</p>
Here's the code that specifically deals with displaying items:
@if (User.Identity.IsAuthenticated)
{
<h4>Items for @User.Identity.Name</h4>
@if (Model["items"].Length == 0)
{
<p>No items have been added yet!</p>
}
<ul>
@foreach (Item item in Model["items"])
{
<li>@Html.ActionLink(@item.Description, "Details", "Items", new { id = @item.ItemId})</li>
}
</ul>
}
else
{
<h4>Items</h4>
<p>Please @Html.ActionLink("log in", "LogIn", "Account") to view or manage your items.</p>
}
In this update, we check User.Identity.IsAuthenticated
to determine whether there's a user that's signed in. If so, then we display the items as well as the user's name. And if not, then we display a message asking the visitor to sign in to view or manage their items.
Next Steps
Now we're ready to go! Start your application and click around. Try accessing the items pages when you are not signed in to see how Identity redirects you to the log in view. Then try accessing the items pages when you are logged in. Next, try logging into different accounts to see that the items in each list are different.
Keep in mind that we've only added simple authorization to our To Do List app. While we can associate items with a specific user, and only display a logged in user's items, as long as you are logged in you can still hack your way to accessing other items in the database by directly entering a URL like https://localhost:5001/Items/Details/1
. That means we have more work to do as far as boosting the security of our details, edit, and delete routes. Using the tools in this lesson, can you think of a way to do this? This task is for your further exploration.
Repository Reference
Follow the link below to view how a sample version of the project should look at this point. Note that this is a link to a specific branch in the repository.
Example GitHub Repo for To Do List with Authentication and Authorization: 3_authorization