Security and identity

Security is a major concern of any modern web application or API. It’s important to keep your user or customer data safe and out of the hands of attackers. This is a very broad topic, involving things like:

  • Sanitizing data input to prevent SQL injection attacks
  • Preventing cross-domain (CSRF) attacks in forms
  • Using HTTPS (connection encryption) so data can’t be intercepted as it travels over the Internet
  • Giving users a way to securely sign in with a password or other credentials
  • Designing password reset, account recovery, and multi-factor authentication flows

ASP.NET Core can help make all of this easier to implement. The first two (protection against SQL injection and cross-domain attacks) are already built-in, and you can add a few lines of code to enable HTTPS support. This chapter will mainly focus on the identity aspects of security: handling user accounts, authenticating (logging in) your users securely, and making authorization decisions once they are authenticated.

Authentication and authorization are distinct ideas that are often confused. Authentication deals with whether a user is logged in, while authorization deals with what they are allowed to do after they log in. You can think of authentication as asking the question, “Do I know who this user is?” While authorization asks, “Does this user have permission to do X?”

The MVC + Individual Authentication template you used to scaffold the project includes a number of classes built on top of ASP.NET Core Identity, an authentication and identity system that’s part of ASP.NET Core. Out of the box, this adds the ability to log in with an email and password.

What is ASP.NET Core Identity?

ASP.NET Core Identity is the identity system that ships with ASP.NET Core. Like everything else in the ASP.NET Core ecosystem, it’s a set of NuGet packages that can be installed in any project (and are already included if you use the default template).

ASP.NET Core Identity takes care of storing user accounts, hashing and storing passwords, and managing roles for users. It supports email/password login, multi-factor authentication, social login with providers like Google and Facebook, as well as connecting to other services using protocols like OAuth 2.0 and OpenID Connect.

The Register and Login views that ship with the MVC + Individual Authentication template already take advantage of ASP.NET Core Identity, and they already work! Try registering for an account and logging in.

Require authentication

Often you’ll want to require the user to log in before they can access certain parts of your application. For example, it makes sense to show the home page to everyone (whether you’re logged in or not), but only show your to-do list after you’ve logged in.

You can use the [Authorize] attribute in ASP.NET Core to require a logged-in user for a particular action, or an entire controller. To require authentication for all actions of the TodoController, add the attribute above the first line of the controller:

Controllers/TodoController.cs

[Authorize]
public class TodoController : Controller
{
    // ...
}

Add this using statement at the top of the file:

using Microsoft.AspNetCore.Authorization;

Try running the application and accessing /todo without being logged in. You’ll be redirected to the login page automatically.

The [Authorize] attribute is actually doing an authentication check here, not an authorization check (despite the name of the attribute). Later, you’ll use the attribute to check both authentication and authorization.

Using identity in the application

The to-do list items themselves are still shared between all users, because the stored to-do entities aren’t tied to a particular user. Now that the [Authorize] attribute ensures that you must be logged in to see the to-do view, you can filter the database query based on who is logged in.

First, inject a UserManager<ApplicationUser> into the TodoController:

Controllers/TodoController.cs

[Authorize]
public class TodoController : Controller
{
    private readonly ITodoItemService _todoItemService;
    private readonly UserManager<ApplicationUser> _userManager;

    public TodoController(ITodoItemService todoItemService,
        UserManager<ApplicationUser> userManager)
    {
        _todoItemService = todoItemService;
        _userManager = userManager;
    }

    // ...
}

You’ll need to add a new using statement at the top:

using Microsoft.AspNetCore.Identity;

The UserManager class is part of ASP.NET Core Identity. You can use it to get the current user in the Index action:

public async Task<IActionResult> Index()
{
    var currentUser = await _userManager.GetUserAsync(User);
    if (currentUser == null) return Challenge();

    var items = await _todoItemService
        .GetIncompleteItemsAsync(currentUser);

    var model = new TodoViewModel()
    {
        Items = items
    };

    return View(model);
}

The new code at the top of the action method uses the UserManager to look up the current user from the User property available in the action:

var currentUser = await _userManager.GetUserAsync(User);

If there is a logged-in user, the User property contains a lightweight object with some (but not all) of the user’s information. The UserManager uses this to look up the full user details in the database via the GetUserAsync() method.

The value of currentUser should never be null, because the [Authorize] attribute is present on the controller. However, it’s a good idea to do a sanity check, just in case. You can use the Challenge() method to force the user to log in again if their information is missing:

if (currentUser == null) return Challenge();

Since you’re now passing an ApplicationUser parameter to GetIncompleteItemsAsync(), you’ll need to update the ITodoItemService interface:

Services/ITodoItemService.cs

public interface ITodoItemService
{
    Task<TodoItem[]> GetIncompleteItemsAsync(
        ApplicationUser user);
    
    // ...
}

Since you changed the ITodoItemService interface, you also need to update the signature of the GetIncompleteItemsAsync() method in the TodoItemService:

Services/TodoItemService

public async Task<TodoItem[]> GetIncompleteItemsAsync(
    ApplicationUser user)

The next step is to update the database query and add a filter to show only the items created by the current user. Before you can do that, you need to add a new property to the database.

Update the database

You’ll need to add a new property to the TodoItem entity model so each item can “remember” the user that owns it:

Models/TodoItem.cs

public string UserId { get; set; }

Since you updated the entity model used by the database context, you also need to migrate the database. Create a new migration using dotnet ef in the terminal:

dotnet ef migrations add AddItemUserId

This creates a new migration called AddItemUserId which will add a new column to the Items table, mirroring the change you made to the TodoItem model.

Use dotnet ef again to apply it to the database:

dotnet ef database update

Update the service class

With the database and the database context updated, you can now update the GetIncompleteItemsAsync() method in the TodoItemService and add another clause to the Where statement:

Services/TodoItemService.cs

public async Task<TodoItem[]> GetIncompleteItemsAsync(
    ApplicationUser user)
{
    return await _context.Items
        .Where(x => x.IsDone == false && x.UserId == user.Id)
        .ToArrayAsync();
}

If you run the application and register or log in, you’ll see an empty to-do list once again. Unfortunately, any items you try to add disappear into the ether, because you haven’t updated the AddItem action to be user-aware yet.

Update the AddItem and MarkDone actions

You’ll need to use the UserManager to get the current user in the AddItem and MarkDone action methods, just like you did in Index.

Here are both updated methods:

Controllers/TodoController.cs

[ValidateAntiForgeryToken]
public async Task<IActionResult> AddItem(TodoItem newItem)
{
    if (!ModelState.IsValid)
    {
        return RedirectToAction("Index");
    }

    var currentUser = await _userManager.GetUserAsync(User);
    if (currentUser == null) return Challenge();

    var successful = await _todoItemService
        .AddItemAsync(newItem, currentUser);

    if (!successful)
    {
        return BadRequest("Could not add item.");
    }

    return RedirectToAction("Index");
}

[ValidateAntiForgeryToken]
public async Task<IActionResult> MarkDone(Guid id)
{
    if (id == Guid.Empty)
    {
        return RedirectToAction("Index");
    }

    var currentUser = await _userManager.GetUserAsync(User);
    if (currentUser == null) return Challenge();

    var successful = await _todoItemService
        .MarkDoneAsync(id, currentUser);
    
    if (!successful)
    {
        return BadRequest("Could not mark item as done.");
    }

    return RedirectToAction("Index");
}

Both service methods must now accept an ApplicationUser parameter. Update the interface definition in ITodoItemService:

Task<bool> AddItemAsync(TodoItem newItem, ApplicationUser user);

Task<bool> MarkDoneAsync(Guid id, ApplicationUser user);

And finally, update the service method implementations in the TodoItemService. In AddItemAsync method, set the UserId property when you construct a new TodoItem:

public async Task<bool> AddItemAsync(
    TodoItem newItem, ApplicationUser user)
{
    newItem.Id = Guid.NewGuid();
    newItem.IsDone = false;
    newItem.DueAt = DateTimeOffset.Now.AddDays(3);
    newItem.UserId = user.Id;

    // ...
}

The Where clause in the MarkDoneAsync method also needs to check for the user’s ID, so a rogue user can’t complete someone else’s items by guessing their IDs:

public async Task<bool> MarkDoneAsync(
    Guid id, ApplicationUser user)
{
    var item = await _context.Items
        .Where(x => x.Id == id && x.UserId == user.Id)
        .SingleOrDefaultAsync();

    // ...
}

All done! Try using the application with two different user accounts. The to-do items stay private for each account.

Authorization with roles

Roles are a common approach to handling authorization and permissions in a web application. For example, it’s common to create an Administrator role that gives admin users more permissions or power than normal users.

In this project, you’ll add a Manage Users page that only administrators can see. If normal users try to access it, they’ll see an error.

Add a Manage Users page

First, create a new controller:

Controllers/ManageUsersController.cs

using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using AspNetCoreTodo.Models;
using Microsoft.EntityFrameworkCore;

namespace AspNetCoreTodo.Controllers
{
    [Authorize(Roles = "Administrator")]
    public class ManageUsersController : Controller
    {
        private readonly UserManager<ApplicationUser>
            _userManager;
        
        public ManageUsersController(
            UserManager<ApplicationUser> userManager)
        {
            _userManager = userManager;
        }

        public async Task<IActionResult> Index()
        {
            var admins = (await _userManager
                .GetUsersInRoleAsync("Administrator"))
                .ToArray();

            var everyone = await _userManager.Users
                .ToArrayAsync();

            var model = new ManageUsersViewModel
            {
                Administrators = admins,
                Everyone = everyone
            };

            return View(model);
        }
    }
}

Setting the Roles property on the [Authorize] attribute will ensure that the user must be logged in and assigned the Administrator role in order to view the page.

Next, create a view model:

Models/ManageUsersViewModel.cs

using System.Collections.Generic;

namespace AspNetCoreTodo.Models
{
    public class ManageUsersViewModel
    {
        public ApplicationUser[] Administrators { get; set; }

        public ApplicationUser[] Everyone { get; set;}
    }
}

Finally, create a Views/ManageUsers folder and a view for the Index action:

Views/ManageUsers/Index.cshtml

@model ManageUsersViewModel

@{
    ViewData["Title"] = "Manage users";
}

<h2>@ViewData["Title"]</h2>

<h3>Administrators</h3>

<table class="table">
    <thead>
        <tr>
            <td>Id</td>
            <td>Email</td>
        </tr>
    </thead>
    
    @foreach (var user in Model.Administrators)
    {
        <tr>
            <td>@user.Id</td>
            <td>@user.Email</td>
        </tr>
    }
</table>

<h3>Everyone</h3>

<table class="table">
    <thead>
        <tr>
            <td>Id</td>
            <td>Email</td>
        </tr>
    </thead>
    
    @foreach (var user in Model.Everyone)
    {
        <tr>
            <td>@user.Id</td>
            <td>@user.Email</td>
        </tr>
    }
</table>

Start up the application and try to access the /ManageUsers route while logged in as a normal user. You’ll see this access denied page:

Access denied error

That’s because users aren’t assigned the Administrator role automatically.

Create a test administrator account

For obvious security reasons, it isn’t possible for anyone to register a new administrator account themselves. In fact, the Administrator role doesn’t even exist in the database yet!

You can add the Administrator role plus a test administrator account to the database the first time the application starts up. Adding first-time data to the database is called initializing or seeding the database.

Create a new class in the root of the project called SeedData:

SeedData.cs

using System;
using System.Linq;
using System.Threading.Tasks;
using AspNetCoreTodo.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace AspNetCoreTodo
{
    public static class SeedData
    {
        public static async Task InitializeAsync(
            IServiceProvider services)
        {
            var roleManager = services
                .GetRequiredService<RoleManager<IdentityRole>>();
            await EnsureRolesAsync(roleManager);

            var userManager = services
                .GetRequiredService<UserManager<ApplicationUser>>();
            await EnsureTestAdminAsync(userManager);
        }
    }
}

The InitializeAsync() method uses an IServiceProvider (the collection of services that is set up in the Startup.ConfigureServices() method) to get the RoleManager and UserManager from ASP.NET Core Identity.

Add two more methods below the InitializeAsync() method. First, the EnsureRolesAsync() method:

private static async Task EnsureRolesAsync(
    RoleManager<IdentityRole> roleManager)
{
    var alreadyExists = await roleManager
        .RoleExistsAsync(Constants.AdministratorRole);
    
    if (alreadyExists) return;

    await roleManager.CreateAsync(
        new IdentityRole(Constants.AdministratorRole));
}

This method checks to see if an Administrator role exists in the database. If not, it creates one. Instead of repeatedly typing the string "Administrator", create a small class called Constants to hold the value:

Constants.cs

namespace AspNetCoreTodo
{
    public static class Constants
    {
        public const string AdministratorRole = "Administrator";
    }
}

If you want, you can update the ManageUsersController to use this constant value as well.

Next, write the EnsureTestAdminAsync() method:

SeedData.cs

private static async Task EnsureTestAdminAsync(
    UserManager<ApplicationUser> userManager)
{
    var testAdmin = await userManager.Users
        .Where(x => x.UserName == "[email protected]")
        .SingleOrDefaultAsync();

    if (testAdmin != null) return;

    testAdmin = new ApplicationUser
    {
        UserName = "[email protected]",
        Email = "[email protected]"
    };
    await userManager.CreateAsync(
        testAdmin, "NotSecure123!!");
    await userManager.AddToRoleAsync(
        testAdmin, Constants.AdministratorRole);
}

If there isn’t already a user with the username [email protected] in the database, this method will create one and assign a temporary password. After you log in for the first time, you should change the account’s password to something secure!

Next, you need to tell your application to run this logic when it starts up. Modify Program.cs and update Main() to call a new method, InitializeDatabase():

Program.cs

public static void Main(string[] args)
{
    var host = BuildWebHost(args);
    InitializeDatabase(host);
    host.Run();
}

Then, add the new method to the class below Main():

private static void InitializeDatabase(IWebHost host)
{
    using (var scope = host.Services.CreateScope())
    {
        var services = scope.ServiceProvider;

        try
        {
            SeedData.InitializeAsync(services).Wait();
        }
        catch (Exception ex)
        {
            var logger = services
                .GetRequiredService<ILogger<Program>>();
            logger.LogError(ex, "Error occurred seeding the DB.");
        }
    }
}

Add this using statement to the top of the file:

using Microsoft.Extensions.DependencyInjection;

This method gets the service collection that SeedData.InitializeAsync() needs and then runs the method to seed the database. If something goes wrong, an error is logged.

Because InitializeAsync() returns a Task, the Wait() method must be used to make sure it finishes before the application starts up. You’d normally use await for this, but for technical reasons you can’t use await in the Program class. This is a rare exception. You should use await everywhere else!

When you start the application next, the [email protected] account will be created and assigned the Administrator role. Try logging in with this account, and navigating to http://localhost:5000/ManageUsers. You’ll see a list of all users registered for the application.

As an extra challenge, try adding more administration features to this page. For example, you could add a button that gives an administrator the ability to delete a user account.

Check for authorization in a view

The [Authorize] attribute makes it easy to perform an authorization check in a controller or action method, but what if you need to check authorization in a view? For example, it would be nice to display a “Manage users” link in the navigation bar if the logged-in user is an administrator.

You can inject the UserManager directly into a view to do these types of authorization checks. To keep your views clean and organized, create a new partial view that will add an item to the navbar in the layout:

Views/Shared/_AdminActionsPartial.cshtml

@using Microsoft.AspNetCore.Identity
@using AspNetCoreTodo.Models

@inject SignInManager<ApplicationUser> signInManager
@inject UserManager<ApplicationUser> userManager

@if (signInManager.IsSignedIn(User))
{
    var currentUser = await userManager.GetUserAsync(User);

    var isAdmin = currentUser != null
        && await userManager.IsInRoleAsync(
            currentUser,
            Constants.AdministratorRole);

    if (isAdmin)
    {
        <ul class="nav navbar-nav navbar-right">
            <li>
                <a asp-controller="ManageUsers" 
                   asp-action="Index">
                   Manage Users
                </a>
            </li>
        </ul>
    }
}

It’s conventional to name shared partial views starting with an _ underscore, but it’s not required.

This partial view first uses the SignInManager to quickly determine whether the user is logged in. If they aren’t, the rest of the view code can be skipped. If there is a logged-in user, the UserManager is used to look up their details and perform an authorization check with IsInRoleAsync(). If all checks succeed and the user is an adminstrator, a Manage users link is added to the navbar.

To include this partial in the main layout, edit _Layout.cshtml and add it in the navbar section:

Views/Shared/_Layout.cshtml

<div class="navbar-collapse collapse">
    <ul class="nav navbar-nav">
        <!-- existing code here -->
    </ul>
    @await Html.PartialAsync("_LoginPartial")
    @await Html.PartialAsync("_AdminActionsPartial")
</div>

When you log in with an administrator account, you’ll now see a new item on the top right:

Manage Users link

More resources

ASP.NET Core Identity helps you add security and identity features like login and registration to your application. The dotnet new templates give you pre-built views and controllers that handle these common scenarios so you can get up and running quickly.

There’s much more that ASP.NET Core Identity can do, such as password reset and social login. The documentation available at http://docs.asp.net is a fantastic resource for learning how to add these features.

Alternatives to ASP.NET Core Identity

ASP.NET Core Identity isn’t the only way to add identity functionality. Another approach is to use a cloud-hosted identity service like Azure Active Directory B2C or Okta to handle identity for your application. You can think of these options as part of a progression:

  • Do-it-yourself security: Not recommended, unless you are a security expert!
  • ASP.NET Core Identity: You get a lot of code for free with the templates, which makes it pretty easy to get started. You’ll still need to write some code for more advanced scenarios, and maintain a database to store user information.
  • Cloud-hosted identity services. The service handles both simple and advanced scenarios (multi-factor authentication, account recovery, federation), and significantly reduces the amount of code you need to write and maintain in your application. Plus, sensitive user data isn’t stored in your own database.

For this project, ASP.NET Core Identity is a great fit. For more complex projects, I’d recommend doing some research and experimenting with both options to understand which is best for your use case.


Licenses and Attributions


Speak Your Mind