12 Securing web applications with ASP.NET Core Identity

This chapter covers

  • Using ASP.NET Core Identity for authentication and authorization
  • Using scaffolding to tweak the ASP.NET Core Identity UI
  • Configuring password options
  • Implementing two-factor authentication
  • Allowing users to log into an application with a third-party account

At the end of 2011, Microsoft released the MS11-100 security advisory (which basically means number 100 in 2011, which is quite a lot, actually). The title of the document, available at http://mng.bz/pOXK, sounds pretty dramatic: “Vulnerabilities in .NET Framework Could Allow Elevation of Privilege.” And, indeed, it was dramatic. In early October of that year, security researchers found a security vulnerability in the built-in ASP.NET user management features. Basically, it was possible to log into an application as an arbitrary user.

The security researchers’ writeup (http://mng.bz/44RR) is an interesting read. According to their description of events, six weeks after reporting the vulnerability, they asked Microsoft for a status update; according to the case manager, an update was expected in February or March, so 4 to 6 months after reporting the issue.

Luckily, someone escalated the vulnerability to the right set of people, and Microsoft released an out-of-bands update to ASP.NET as part of MS11-100 and made the then-lead of the ASP.NET team, Scott Guthrie (now executive VP of the Cloud and AI group at Microsoft) write a blog post (http://mng.bz/OoWw) urging his readers to install the patch.

This was not the first time that a patch for .NET or ASP.NET had to be released on a day other than “Patch Tuesday” (which is the second Tuesday each month and the day Microsoft routinely rolls out updates). In September 2010, something similar happened: a severe security vulnerability was found, patched (as Guthrie said then: after working through the weekend), and blogged about at http://mng.bz/YGvz.

I’m not telling this story to make fun of the security record of .NET or ASP.NET. To the contrary, ASP.NET Core especially has had an excellent track record so far (fingers crossed!), and its open source approach to developing makes it easier for more eyes to have a good look at the code’s security. The example from 2011 teaches another highly important lesson: issues, once they get reported, get fixed. The internet is full of stories where custom implementations of security features like authentication, session management, or password storage get hacked because they are just not on par with industry standards. And even though ASP.NET messed up once or twice in the past, the technology is under constant scrutiny. So instead of arguing, “I’ll roll my own security, because I do not trust the thousands of people developing .NET,” you should embrace the de facto standards provided by the framework, and this also includes user management.

The story from 2011 referred to ASP.NET Web Forms, and we are working with something entirely different this time. Still, ASP.NET Core comes with a built-in system for user management: ASP.NET Core Identity (sometimes just referred to as Identity). This chapter will get you started and will show you the most important of the available features and how to tweak them. The goal is not to provide a complete coverage of everything in ASP.NET Core Identity (this might be a task for another book), but to tell you enough for you to be able to use it and to understand how it works and why it is considered secure.

12.1 ASP.NET Core Identity setup

When creating a new ASP.NET Core project, some of the ASP.NET Core Identity features are already built in—if you create the project correctly. The easiest way to do that is to use Visual Studio. Creating a new ASP.NET Core web application, whether you use Razor Pages or MVC, gives you the Authentication Type option as part of the wizard, shown in figure 12.1.

CH12_F01_Wenz

Figure 12.1 Choosing the authentication type in the Visual Studio project creation wizard

If you pick the Individual Accounts option, the application will come with user management out of the box. Launching the application will lead to a home page that looks like the one in figure 12.2. Notice the Register and Login links in the top right corner.

CH12_F02_Wenz

Figure 12.2 The application generated by the template offers registration and login features.

With no additional configuration or code, you can register new users for the application and then log in with those credentials.

Note If you are using Visual Studio Code or any other IDE, you can still get access to those features—just create the web application using the dotnet CLI tool. The command dotnet new webapp --auth Individual -o NameOfApp does the trick (you can also use mvc instead of webapp to use MVC instead of Razor Pages).

If you look at the project, you might wonder how that is even possible. The URL of the registration page, for instance, looks similar to https://localhost:12345/Identity/Account/Register, but there is no view or Razor Page called that. The pages and the implementation logic are “hidden” in the form of a Razor class library. The user data is stored in a LocalDB database within the project; the project template automatically sets that up, including Entity Framework migrations.

But no worries; you can look at the implementation and change every detail of it. The easiest way to get started is to use scaffolding to add the hidden content to the application. In order to do so, right-click the web application project in Visual Studio’s Solution Explorer, and choose the Add/New Scaffolded Item option. Choose the Identity option, and wait a few seconds for Visual Studio to initialize code generation in the background.

TIP If you receive an error message, just click the Add button again, and it will likely work (it’s a common bug that seems to pop up from time to time).

The next and final step of the wizard is shown in figure 12.3: you get a list of all the available pages that may be scaffolded.

CH12_F03_Wenz

Figure 12.3 Select which views or pages to generate.

It’s a long list, and you probably only want to generate those pages that you want to change—for instance, to amend the layout. The Override All Files checkbox activates all the pages, which will lead to generating all of them.

You are also required to pick a data context class. Select one that’s already part of the application, or create a new data context. This is used so that the application knows where to store and retrieve user management data. If you already have a class that represents a user in the application, you may provide it in the wizard as well. This step is not mandatory, however.

After a short while, Visual Studio has generated all the required files, so you can now see exactly what the default implementation looks like. Figure 12.4 shows the result of that process in a Razor Pages application; the outcome looks very similar for ASP.NET Core MVC.

CH12_F04_Wenz

Figure 12.4 The Solution Explorer after scaffolding

The AreasIdentityPagesAccount folder is filled with over a dozen Razor Pages (most with an associated page class); the Areas IdentityPagesAccountManage folder (not expanded in figure 12.4) contains a similar number of files.

Identity scaffolding with the CLI

The scaffolding process is very convenient when using Visual Studio, but a bit more cumbersome when relying on the terminal. Here is what you need to do to get everything up and running. First of all, install the following NuGet packages to your project (using dotnet add package):

  • Microsoft.AspNetCore.Identity.EntityFrameworkCore

  • Microsoft.AspNetCore.Identity.UI

  • Microsoft.EntityFrameworkCore.SqlServer

  • Microsoft.EntityFrameworkCore.Tools

  • Microsoft.VisualStudio.Web.CodeGeneration.Design

  • Microsoft.EntityFrameworkCore.Design

Then install the ASP.NET Core code generation tool:

dotnet tool install -g dotnet-aspnet-codegenerator

Finally, run the scaffolding tool. Make sure that you are in the project folder, and run either the dotnet-aspnet-codegenerator tool or dotnet aspnet- codegenerator. Using identity -h as an argument gives you all available options, and dotnet aspnet-codegenerator identity -lf provides a list of all files that may be generated. You may then choose what will be scaffolded.

Time to look what the template has to offer! We will use the Razor Pages template, but you will also be able to follow along when using an MVC application, since the base concepts will be the same (just some filenames or URLs may differ).

12.2 ASP.NET Core Identity fundamentals

The first place to look for configuration options is in Program.cs (or, when using the configuration pattern from .NET versions prior to 6, Startup.cs). A database context is set up so that ASP.NET Core Identity may store users and other information. The vital setting to add Identity to the project looks like this:

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

The code may look a bit different in your project, especially if you have created classes for the users and for the database context, but in essence, this sets up the Identity system. The code also shows how to set options: just provide them in the lambda expression within the call to the AddDefaultIdentity() method (we will look at other configuration options later in this chapter).

Every page in the application contains the Registration and Login links. After logging into the application (and, of course, registering a user first), a Manage and a Logout link appear. This must have been defined in the main template, the PagesShared\_Layout.cshtml file. We find a reference to a partial called _LoginPartial there:

<partial name="_LoginPartial" />

The file _LoginPartial.cshtml resides in the same folder and has the following structure:

@using Microsoft.AspNetCore.Identity
@inject SignInManager<IdentityUser> SignInManager
 
@if (SignInManager.IsSignedIn(User))
{
    // Manage/Logout links
}
else
{
    // Register/Login links
}

The SignInManager that is automatically inserted here via dependency injection provides helper functionality, such as whether a user is logged in. The User property contains the current user, if any. The SignInManager class also handles logging the user in and out and issues the authentication cookie that is used by ASP.NET Core to recognize the user.

Let’s look at the registration page first, Register.cshtml. It first and foremost contains a form with an email and a password field. Upon form submission, the OnPostAsync() method in the Register.cshtml.cs file is called. The code looks like this (edited and shortened a bit, by removing error handling, comments, logging, and external logins):

public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
    returnUrl ??= Url.Content("~/");
    if (ModelState.IsValid)
    {
        var user = new IdentityUser {                                   
        UserName = Input.Email, Email = Input.Email };                
        var result = await _userManager.CreateAsync(                    
        user, Input.Password);                                        
        if (result.Succeeded)
        {
            var code = await _userManager
GenerateEmailConfirmationTokenAsync(user);                              
            code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
            var callbackUrl = Url.Page(
                "/Account/ConfirmEmail",
                pageHandler: null,
                values: new { area = "Identity", userId = user.Id, code = code,
                returnUrl = returnUrl },
                protocol: Request.Scheme);
 
            await _emailSender.SendEmailAsync(Input.Email,              
            "Confirm your email",                                     
                $"Please confirm your account by                        
                <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>  
                clicking here</a>.");                                 
 
            if (_userManager.Options.SignIn                             
            .RequireConfirmedAccount)                                 
            {                                                           
                return RedirectToPage(                                  
                "RegisterConfirmation", new {                         
                email = Input.Email, returnUrl = returnUrl });        
            }
            else
            {
                await _signInManager.SignInAsync(
                user, isPersistent: false);                           
                return LocalRedirect(returnUrl);
            }
        }
    }
 
    return Page();
}

Tries to create the user

Generates a confirmation token (opt-in)

Sends an opt-in email to confirm the email address

If a confirmed account is required to sign in, this redirects to the confirmation page.

If no confirmed account is required to sign in, then sign in the user.

There is quite a lot going on in this code, so let’s look at everything that happens. First, the code instantiates the IdentityUser class, which is the default class to represent a user within ASP.NET Core Identity, with the username and email address.

Tip If you have chosen a custom user class in the wizard, that’s the one that will be used. This enables you to use custom properties for the user (e.g., first name and last name). If you choose to implement that at a later stage, create a new class for your user, inheriting from IdentityUser, and replace IdentityUser with your new class wherever you find it in the scaffolded code.

The CreateAsync() method of the dependency-injected UserManager class then attempts to create the user. If that succeeds, the UserManager instance then generates a one-time token that will be used to confirm the account—a typical opt-in mechanism. The GenerateEmailConfirmationTokenAsync() method, using the previously created IdentityUser as an argument, takes care of that. This token is then sent to the provided email address, linking back to the /Account/ConfirmEmail page (which we will look at in a moment).

By default, the confirmation email functionality is disabled, since email server setups can greatly differ: SMTP, SendGrid, Graph API, and many others. Instead, the RegistrationConfirmation.cshtml.cs file sets the DisplayConfirmAccountLink variable to true. This means that the confirmation email is not sent; instead, the confirmation link is directly shown after registration. This is, of course, no viable option for a production system—since it allows users to register without validating their email addresses—so this offending line of code needs to be removed:

DisplayConfirmAccountLink = true;

In order to be able to send an email, ASP.NET Core Identity expects a service fulfilling the IEmailSender interface (which is defined in the Microsoft.AspNetCore .Identity.UI.Services namespace). The class needs to implement the SendEmailAsync() method. The implementation details depend on how you want to send the email, but the following listing shows code that uses SMTP, and .NET’s SmtpClient class. The SmtpClient instance will send the email to the provided recipient.

Listing 12.1 The SMTP email service

using Microsoft.AspNetCore.Identity.UI.Services;
using System.Net.Mail;
 
namespace AspNetCoreSecurity.IdentitySamples.Classes
{
    public class SmtpEmailSender : IEmailSender     
    {
        public Task SendEmailAsync(string email, string subject, 
        string htmlMessage)
        {
            var smtpClient = new SmtpClient
            {
                Port = 25,                          
                Host = "localhost",                 
                DeliveryMethod =                    
                SmtpDeliveryMethod.Network,       
                UseDefaultCredentials = false       
            };
 
            return smtpClient.SendMailAsync(        
                "website@localhost", email,         
                subject, htmlMessage);            
        }
    }
}

Implements the IEmailSender interface

Shows the SMTP configuration options

Sends the email

Note You will notice that there are some hardcoded values in the code. In a real-world application, these settings will certainly be put into a configuration file (see chapter 7).

In order to make this class available to the ASP.NET Core Identity system, it needs to be registered as a service. Add the following call to Program.cs:

builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();

After these steps, the confirmation email is sent properly. For convenient local testing, I often use the smtp4dev fake SMTP email server from https://github.com/rnwood/smtp4dev. It comes in the form of a .NET tool and can be installed from the command line as follows:

dotnet tool install -g Rnwood.Smtp4dev

This installs the smtp4dev executable on the system. When you run it, there are now both an SMTP server available on port 25 and a web UI on ports 5000 (HTTP) and 5001 (HTTPS). Figure 12.5 shows this web interface with a confirmation email coming from ASP.NET Core Identity; the link URL looks like this (the code URL parameter is the token):

https://localhost:12345/Identity/Account/ConfirmEmail?userId=92c7a...&code=Q2ZESj...&returnUrl=%2F

CH12_F05_Wenz

Figure 12.5 The opt-in email with the confirmation link

The standard template requires that this confirmation link actually be called. Remember the configuration setting from earlier in this chapter?

services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)

Setting SignIn.RequireConfirmedAccount to false would log the user in right after registration. Otherwise, the ConfirmEmail.cshtml page calls the UserManager’s ConfirmEmailAsync() method, which then verifies whether the code in the confirmation URL is correct. If so, the user is considered confirmed.

The next page to analyze is Login.cshtml, which contains a simple HTML form to log in the user. The actual login takes place in the Login.cshtml.cs file and looks like this (once again edited to show just the vital components):

public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
    returnUrl ??= Url.Content("~/");                      
 
    if (ModelState.IsValid)
    {
        var result = await _signInManager.PasswordSignInAsync(
        Input.Email, Input.Password, 
        Input.RememberMe, lockoutOnFailure: false);     
        if (result.Succeeded)
        {
            return LocalRedirect(returnUrl);              
        }
    }
 
    return Page();
}

Determines the URL to redirect to after login

Shows the login using the SignInManager

Redirects to the URL determined earlier

The logic of how to determine whether a username/password combination is correct or not happens in the SignInManager instance. This allows ASP.NET Core Identity to support a variety of different database schemes and authentication mechanisms. The PasswordSignInAsync() method not only validates the credentials provided, but also persists the logged-in state of the user (if signing in is successful). By default, a cookie is used for that.

Note Logging out works in a similar fashion; this time, the SignOutAsync() method of the SignInManager instance is used after doing a POST request to make CSRF harder (see chapter 4).

Once a user is logged in, the HttpContext’s User property (which is of type ClaimsPrincipal) is populated with the user. User.Identity then contains the identity information of the user, including their username (User.Identity.Name). This is output in the LoginPartial.cshtml template in the link to the user management page:

<a class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Manage/Index" title="Manage">Hello @User.Identity?.Name!</a>

Being able to log in users is certainly nice, but the most common use case is to provide registered users access to some resources that anonymous users do not have. As you have previously seen, a call to SignInManager.IsSignedIn(User) determines whether a user is currently logged in, but this looks like a cumbersome approach to protect individual pages from anonymous access. Instead, it is usually much more convenient to just configure this behavior. The [Authorize] attribute, when applied to a Razor Page class, a controller class, or a controller method, protects the associated page or view(s).

Note You cannot apply [Authorize] to individual Razor Page class methods like OnGet() or OnPost(). If you want to handle different HTTP methods differently, it’s better to use the MVC framework. You may also use the [AllowAnonymous] attribute to grant anonymous access to a resource (consider an MVC controller with the [Authorize] attribute, where one individual view will be open to anyone). Even if your whole site is protected from unauthorized access, the login page should be available to all.

The next listing shows a simple page that outputs the logged in user’s name (similar to before), and listing 12.3 shows the associated page class that protects the resource.

Listing 12.2 The protected page

@page
@model AspNetCoreSecurity.IdentitySamples.Pages.ProtectedModel
@{
    ViewData["Title"] = "Protected page";
}
 
<div class="text-center">
    <h1 class="display-4">Welcome, @User.Identity?.Name</h1>
</div>

Listing 12.3 The page class, protecting the page

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
 
namespace AspNetCoreSecurity.IdentitySamples.Pages
{
    [Authorize]
    public class ProtectedModel : PageModel
    {
        public void OnGet()
        {
        }
    }
}

If no user is logged in, trying to access the protected page (in our example, /Protected) immediately leads to a redirect URL with the following pattern:

https://localhost:12345/Identity/Account/Login?ReturnUrl=%2FProtected

The ReturnUrl query string parameter contains the URL we actually wanted to load. This makes sure that after login, the application can redirect us back to the original target page. There are two more pieces required to complete the puzzle and make all of this work:

  • ASP.NET Core’s authentication middleware, among other things, reads out the authentication cookie created after logging in, updates the User object, and creates the ClaimsPrincipal.

  • ASP.NET Core’s authorization middleware authorizes the user to access a resource, which the [Authorize] attribute relies on.

The relevant calls can be found in the Program.cs file:

app.UseAuthentication();
app.UseAuthorization();

Warning These two calls must be in precisely the order shown (first UseAuthentication(), then UseAuthorization()). At least the latter must come after a call to UseRouting() (to have access to routing information), and both need to happen before UseEndpoints() calls (to authenticate prior to endpoint access).

It is also possible to use role-based authorization. Users may have an arbitrary number of roles, and access may be granted only if the user holds at least one fitting role. In order to do so, the [Authorize] attribute may be changed like this:

[Authorize(Roles="Administrator,Supervisor")]

The class responsible for managing roles is RoleManager, but it is not injected by default (not all applications need roles). In order to get access to that class, the ASP.NET Core Identity middleware needs to be configured appropriately:

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

Note The RoleManager class is used to create roles. Applying a role to or removing a role from a user is done by the UserManager class.

But how does all of this work? The magic lies primarily in the UserManager and SignInManager implementations that ship with ASP.NET Core. Figure 12.6 shows the database schema that is generated.

CH12_F06_Wenz

Figure 12.6 The schema of the Identity database

Several of the database tables directly map to data and features we have discussed so far:

  • AspNetUsers contains the users.

  • AspNetRoles holds all user roles, and AspNetUserRoles stores which users hold which roles.

  • For users and roles, AspNetUserClaims and AspNetRoleClaims contain associated claims

Obviously, the database schema follows the best practices from chapter 8. There is no database column for the password; instead, a hash is stored, making a data leak a little bit less catastrophic.

Note If you are curious, the default implementation for password hashing is in this file: http://mng.bz/GEDD. For .NET 6, the hashing algorithm is PBKDF2 with 10,000 iterations. Chapter 8 describes how you can increase this value.

For many common scenarios, this database structure works really well. As soon as you want extra features not covered by the built-in implementation, you can get them just by using the available interfaces.

Instead of doing that, however, let’s look at more built-in features that do require some extra configuration.

12.3 Advanced ASP.NET Core Identity features

So far, most of the implementation has been scaffolded for us (which is actually a pretty good starting point). There are many ways to tweak what we have and to explore and implement additional features.

12.3.1 Password options

When registering at the web application, chances are that your first pick of password was rejected. Figure 12.7 shows the associated error message. By default, the password has to meet these criteria:

  • At least six characters long

  • At least one lowercase letter

  • At least one uppercase letter

  • At least one digit

  • At least one nonalphanumeric character

CH12_F07_Wenz

Figure 12.7 The password is not strong enough.

We’ve already discussed password requirements in chapter 8, arguing that the length of the password is paramount, much more important than whether or not there is a special character in it. Since 2020, NIST has recommended that passwords should be at least eight characters long, and no complexity requirements (uppercase/lowercase letter, digits, special characters) should be used. If we want to apply this rule to our applications, we can configure that in Program.cs when setting the Identity options.

Here are the default settings that include the complexity requirements:

services.AddDefaultIdentity<IdentityUser>(options => {
    options.SignIn.RequireConfirmedAccount = true;
 
    options.Password.RequireDigit = true;               
    options.Password.RequiredLength = 6;                
    options.Password.RequiredUniqueChars = 1;           
    options.Password.RequireLowercase = true;           
    options.Password.RequireNonAlphanumeric = true;     
    options.Password.RequireUppercase = true;           
});

The password must contain at least one digit.

The password must be at least six characters long.

The password must contain at least one character (self-evident).

The password must contain at least one lowercase letter.

The password must contain at least one nonalphanumeric character.

The password must contain at least one uppercase letter.

Note The RequiredUniqueChars option has an effect only for values greater than 1. For instance, if set to 2, the password needs to consist of at least two different characters.

If you want to set a minimum password length of eight characters, at least six of which have to be different, and no other restrictions should apply, you can use these settings:

services.AddDefaultIdentity<IdentityUser>(options => {
    options.SignIn.RequireConfirmedAccount = true;
 
    options.Password.RequireDigit = false;
    options.Password.RequiredLength = 8;
    options.Password.RequiredUniqueChars = 6;
    options.Password.RequireLowercase = false;
    options.Password.RequireNonAlphanumeric = false;
    options.Password.RequireUppercase = false;
});

There is no setting for maximum password length. And, truth to be told, since passwords must be hashed anyway, there should be no such restriction. The template does limit the password length, but at least to a sensible maximum of 100 characters. This setting is hidden in the RegistrationModel (Registration.cshtml.cs file) by using the [StringLength] attribute (see chapter 5 for more information). Here is the complete class:

public class InputModel
{
    [Required]
    [EmailAddress]
    [Display(Name = "Email")]
    public string Email { get; set; }
 
    [Required]
    [StringLength(100, ErrorMessage = 
    "The {0} must be at least {2} and at max
    {1} characters long.", MinimumLength = 6)]
    [DataType(DataType.Password)]
    [Display(Name = "Password")]
    public string Password { get; set; }
 
    [DataType(DataType.Password)]
    [Display(Name = "Confirm password")]
    [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
    public string ConfirmPassword { get; set; }
}

12.3.2 Cookie options

After successfully authenticating a user, an authentication cookie will be set with a long, impossible-to-guess value. Figure 12.8 shows how this may look like.

CH12_F08_Wenz

Figure 12.8 The ASP.NET Core authentication cookie with the default settings

The following cookie settings can be detected by looking at the figure:

  • The cookie is called AspNetCore.Identity.Application.

  • The HttpOnly and secure flags are set (the latter one only when HTTPS is being used).

  • The SameSite mode is Lax.

  • The cookie expires when the browser is closed, so no explicit expiration date is set.

All of these settings—and more!—may be changed in Program.cs by calling the ConfigureApplicationCookie() method. Figure 12.9 shows the cookie after applying these settings:

Builder.Services.ConfigureApplicationCookie(options =>
    {
        options.Cookie.Name = "MyAuthenticationCookie";           
        options.Cookie.HttpOnly = true;                           
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;  
        options.Cookie.SameSite = SameSiteMode.Strict;            
        options.ExpireTimeSpan = TimeSpan.FromMinutes(20);        
        options.SlidingExpiration = true;                         
    });

Shows the cookie name

Displays the HttpOnly flag

Shows Secure flag

Shows SameSite mode

Reveals cookie expiration date

Indicates whether the expiration window shall restart on each subsequent HTTP request

CH12_F09_Wenz

Figure 12.9 The ASP.NET Core authentication cookie with the updated settings

All the configuration updates are reflected in the cookie, except for the expiration date, which is stored in the payload of the cookie. Two new cookies were created, ai_user and ai_session, and they implement the expiration window.

12.3.3 Locking out users

Punishing users for failed password attempts is a measure with side effects. Temporarily locking out users after a number of incorrect passwords may prevent brute-forcing passwords, but is also an easy mechanism to, well, prevent users from legitimately accessing a web application. If the attacker knows their victim’s username, it’s easy to lock them out if the application supports that. There are three lockout settings that ASP.NET Core Identity supports:

  • MaxFailedAccessAttempts—The number of failed login attempts before an account is locked; defaults to five

  • DefaultLockoutTimeSpan—How long users are locked out of the application; defaults to 5 minutes

  • AllowedForNewUsers—Whether new users may also be locked out (before even logging in once); defaults to true

These options may be applied when calling AddDefaultIdentity(); the default values are explicitly set in the following code snippet:

services.AddDefaultIdentity<IdentityUser>(options => {
    ...
    options.Lockout.MaxFailedAccessAttempts = 5;
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
    options.Lockout.AllowedForNewUsers = true;
});

When logging in, using the SignInManager’s PasswordSignInAsync() method, you can overwrite the locking behavior. One scenario would be that you prioritize usability over security and do not want users to get locked out if someone else knows their username and just randomly tries out passwords. The scaffolded template actually does that by using the following call in the Login.cshtml.cs file:

var result = await _signInManager.PasswordSignInAsync(Input.Email,Input.Password, Input.RememberMe, lockoutOnFailure: false);

12.3.4 Working with claims

We have used the term claims before in this chapter, albeit only briefly. We saw that User is of type ClaimsPrincipal, and we also noticed the AspNetUserClaims and AspNetRoleClaims tables in the automatically created database schema. What exactly is a claim? In the context of ASP.NET Core Identity, a claim is basically a piece of information about a user. Claims were first somewhat standardized in the web services era in the 2000s, when SOAP was still extremely popular, and RESTful APIs using JSON were uncommon, to say the least. The OASIS Open initiative (www.oasis-open.org/) was a joint venture of several organizations (including Microsoft) and tried to set standards for web services. Some of the approaches back then are still in use today, as you will see shortly.

We will add a UI to display the user’s claims has and integrate the UI into the already scaffolded application, within the AreasIdentityPagesAccountManage folder. The User object has a Claims property, which we iterate over and output all contents. The following listing shows how that is done in the newly created Claims.cshtml file.

Listing 12.4 Listing all claims

@page
@{
    ViewData["Title"] = "Claims";
    ViewData["ActivePage"] = ManageNavPages.Claims;
}
 
<h4>@ViewData["Title"]</h4>
 
<div class="row">
    <div class="col-md-6">
        <p>Here is a list of your claims:</p>
        <p>
            <ul>
                @{
                    foreach (var claim in User.Claims)
                    {
                        <li>@claim.Type: @claim.Value</li>
                    }
                }
            </ul>
        </p>
    </div>
</div>

Each claim has a Type and Value property, which the preceding code outputs in a list. There are two more steps required to make that page appear in the account management UI. First, add two new entries to the ManageNavPages class:

using Microsoft.AspNetCore.Mvc.Rendering;
 
namespace
AspNetCoreSecurity.IdentitySamples.Areas.Identity.Pages.Account.Manage
{
    public static class ManageNavPages
    {
...
        public static string Claims => "Claims";
...
        public static string ClaimsNavClass(ViewContext viewContext) => 
        PageNavClass(viewContext, Claims);
...
    }
}

Finally, add a new navigation link to the <ul> list in the _ManageNav.cshtml file:

<li class="nav-item"><a class="nav-link @ManageNavPages.ClaimsNavClass(ViewContext)" id="claims" asp-page="./Claims">Claims</a></li>

The claims page now shows up in the navigation and is also accessible via its direct URL, https://localhost:12345/Identity/Account/Manage/Claims (using your local port number, of course). Figure 12.10 shows a typical output.

CH12_F10_Wenz

Figure 12.10 All of the user’s claims

The user we are logged in with currently has five claims:

  • A name identifier (basically the user’s unique ID).

  • A name (in our case, the email address, since the registration form did not ask for more information).

  • An email address.

  • A security stamp (which is basically a fingerprint of the user’s credentials and changes once the password is updated). This is specific to ASP.NET Core Identity.

  • The authentication method used (amr stands for authentication methods references); pwd obviously means password.

The UserManager class provides a variety of helper methods to handle claims for a given user:

  • AddClaimAsync(user, claim)—Adds a new claim to a user

  • AddClaimsAsync(user, listOfClaims)—Adds several new claims to a user

  • GetClaimsAsync(user)—Retrieves all of a user’s claims

  • GetUsersForClaimAsync(claim)—Retrieves all users who have a given claim

  • RemoveClaimAsync(user, claim)—Removes a claim from a user

  • RemoveClaimsAsync(user, listOfClaims)—Removes several claims from a user

  • ReplaceClaimAsync(user, oldClaim, newClaim)—Replaces one claim for a user with another one

Authorization with claims

Instead of role-based authentication, you could also determine the claims a user has and then decide which of the contents of the current page the user is allowed to see, if any. Policies make that relatively easy. Call AddAuthorization() in Program.cs, set up policies there, and provide the claim that must be present so that the policy is met. Here is how that will look:

builder.Services.AddAuthorization(options => {
    options.AddPolicy(
        "ManningAuthors",
        policy => policy.RequireClaim("Publisher", "Manning"));
});

Then provide the policy name in the [Authorize] attribute like this:

[Authorize(Policy = "ManningAuthors")]
public class ProtectedManningModel : PageModel

Both role-based authorization and claims-based authorization have their place. A claim is information about the user, whereas a role is more of a category for users sharing certain security privileges. Choose what suits your application model best. Mike Brind’s ASP.NET Core Razor Pages in Action (Manning, 2022) covers this in more depth, including code examples.

12.3.5 Two-factor authentication

One of the most secure approaches to preventing account theft is to add a second factor to logging in. The process is called two-factor authentication (2FA). The second factor (the password being the first one) is often a one-time code, either in a text message or in one of the authenticator apps (those from Google and Microsoft are the most commonly used ones). If the mobile device with the authenticator app stops working, previously generated recovery codes grant a “back door” to the system, allowing login and, for instance, configuring another device.

Conveniently, the scaffolded ASP.NET Core Identity app is already prepared for 2FA, so we can look at the implementation and see how that works and what can be configured. The two-factor authentication section of the management UI (https://localhost:12345/Identity/Account/Manage/TwoFactorAuthentication), shown in figure 12.11, helps set everything up.

CH12_F11_Wenz

Figure 12.11 The two-factor authentication settings page

But before you attempt to configure the authenticator app, let’s first tweak the application a little (which will then make 2FA setup a little bit easier). We need to include a QR code generation library that does not ship as part of the scaffolded templates. The ASP.NET Core team recommends QRCode.js by Shim Sangmin. The code is available on GitHub at https://github.com/davidshimjs/qrcodejs. The code was last updated in 2015, but it still works well (you might still consider scouting for a library that is actively maintained). Also, the library is trivially accessible; you just download one JavaScript file and you’re ready to go, without any package management requirements.

A ZIP package with the QR code library is available at http://mng.bz/z4z1. In that archive, find the qrcode.min.js file and copy it to the wwwrootjs folder of the web application. In the same folder, create a file called enableAuthenticator.js with the following content.

Listing 12.5 JavaScript code to create a QR code for authenticator

$(function() {
    new QRCode(                                    
        $("#qrCode")[0],                           
        {
            text: $("#qrCodeData").data("url"),    
            width: 150,
            height: 150
        });
});

Creates a new QR code

Provides placeholder where the QR code will appear

Retrieves the URL encoded in the QR code from the qrCodeData HTML element

This code snippet creates a QR code in a placeholder on the page (with the ID qrCodeData). Include both JavaScript files in the Scripts section of the EnableAuthenticator.cshtml page (in the AreasIdentityPagesAccountManage folder):

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
    <script src="~/js/qrcode.min.js"></script>
    <script src="~/js/enableAuthenticator.js"></script>
}

Make sure that you first load the QR code library, then the custom. Next, locate the following markup in the CSHTML page:

<div class="alert alert-info">Learn how to <a href="https://go.microsoft.com/fwlink/?Linkid=852423">enable QR code generation</a>.</div>

You have already learned how to enable QR code generation, so just remove this <div> element. If you refresh the page, you will see something very similar to figure 12.12.

CH12_F12_Wenz

Figure 12.12 The page now contains a QR code.

Use an authenticator app—the most common choices are the ones from Microsoft and Google—and scan the QR code. You will then have an entry in that app for the application and see a time-based one-time password (also called TOTP) that changes every 30 seconds or so (figure 12.13).

CH12_F13_Wenz

Figure 12.13 The authenticator app shows a TOTP for the web application.

Note If you are not happy with the application name shown in the authenticator app, go to the EnableAuthenticator.cshtml.cs file and look for the GenerateQrCodeUri() method. There, you will easily see the string that also shows up in the authenticator app. Change it at will.

Make sure that you type in the current TOTP into the text field on the 2FA page of the application, and click the Verify button. If the TOTP is correct, 2FA is correctly set up. You will also get ten recovery codes that allow you to log into the application even without the device with the authenticator app—but only once per code.

Next time you try to log in with your username and password, you will be prompted to enter a one-time code from the authenticator app (figure 12.14). You can also click the “You can log in with a recovery code” link and “waste” one of your ten lifelines.

CH12_F14_Wenz

Figure 12.14 Two-factor authentication requires a TOTP to log in.

This was pretty seamless to set up, but which APIs were doing the heavy lifting in the background? Spoiler: the UserManager and SignInManager classes contain all we need. The former class takes care of setting up 2FA, and the latter one is used when logging in.

Let’s start with the registration process. The following methods set up the user appropriately:

  • SetTwoFactorEnabledAsync(user, true)—Enables 2FA for a given user

  • GenerateNewTwoFactorRecoveryCodesAsync(user, count)—Generates count new recovery codes

  • GetAuthenticatorKeyAsync(user)—Creates the key used in the authenticator app (the QR code creation will not be covered here)

On the login page (Login.cshtml.cs), after validating the credentials, the SignInManager class determines whether the user has 2FA enabled. If so, the return value of the PasswordSignInAsync() has the RequiresTwoFactor property set to true. In that case, a redirection to LoginWith2fa.cshtml takes place:

var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, 
Input.RememberMe, lockoutOnFailure: false);
...
if (result.RequiresTwoFactor)
{
    return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, 
    RememberMe = Input.RememberMe });
}

On that page, the user is prompted to enter the TOTP code the authenticator app is currently displaying. The validation of that code is very easy because the SignInManager class has all we need. First, the user currently trying to sign in is determined (this is made possible courtesy of a temporary cookie):

var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();

Then the TwoFactorAuthenticatorSignInAsync() validates the authenticator code:

var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, Input.RememberMachine);

Note that this method does not require the user object, because the identity is read from the temporary cookie from earlier. If the user wants to use their recovery codes, they can go to the LoginWithRecoveryCode.cshtml page. The page model class again refers the heavy lifting to the SignInManager. First, the code validates whether there’s really a user trying to sign in:

var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();

Next, the user-provided recovery code is validated with the aptly named TwoFactorRecoveryCodeSignInAsync() method:

var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);

It looks like magic, and the inner workings are not really trivial, but ASP.NET Core Identity comes with an almost ready-to-use solution.

Note Since this chapter mentioned text messages as an alternative mechanism for the second factor, the ASP.NET Core documentation has detailed instructions for several SMS services at http://mng.bz/06BJ.

12.3.6 Authenticating with external providers

A study claims that the average user has about 100 passwords (see http://mng.bz/KxdX). For some, logging into a third-party application using one of their existing accounts from Google, Facebook, Twitter, Apple, or others is an attractive proposition. Chapter 13 will show more details about this scenario when covering OAuth and OpenID Connect, the standard used for this functionality. But since we already have a scaffolded app and ASP.NET Core Identity supports third-party authentication services, we can take a look.

The implementation details vary between providers, but the usual steps are along the following lines:

  1. Install a NuGet package specific to the authentication provider.

  2. Register an application with the provider, which should give you app credentials (usually an ID and a secret token).

  3. Store those credentials in the ASP.NET Core app, and let the NuGet package and ASP.NET Core Identity do their magic.

As an example, we are using Microsoft accounts for authentication. An application in Microsoft Azure’s cloud will serve as the link between our application and the Microsoft accounts. Be aware that costs may incur for setting up this app. Go to the “App registrations” section in Azure, either by using the search bar on top or by trying this direct link (which obviously is subject to change): https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade. Click the New Registration link, and register a new application. Figure 12.15 shows the form where you can enter the app details.

CH12_F15_Wenz

Figure 12.15 Registering an application in Microsoft Azure

Provide a name for the application, and choose the “Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g., Skype, Xbox)” option. In the “Redirect URI” section, enter a URL consisting of the root of your web application, concatenated with /signin-microsoft. For our sample application, this results in https://localhost:5001/signin-microsoft, but make sure you use the address of your server. The signin-microsoft endpoint does not exist yet; the NuGet package will register it later.

Click Register, and on the page that follows, write down the application ID shown (a long GUID). Then search for the Certificates & Secrets link in the navigation bar on the left-hand side. Click that element, and create a new client secret (you only have to fill out the description). Afterward, you will see something like figure 12.16. The secret value (labeled “Value”) is relevant for the next steps, not the GUID labeled “Secret ID.0”

CH12_F16_Wenz

Figure 12.16 The client secret has been created.

Back in the web application, add the application ID from Azure and the secret value to the appsettings.json configuration file (or use some of the other options for storing secrets from chapter 7). The structure and names for those configuration options are arbitrary as long as you use the same approach when reading out this information later:

{
  "MicrosoftAuthentication": {
    "AppID": "212ff228-effd-4111-8118-878ade97abad",
    "Secret": "QyU7Q~XViIZ-ce~3IPueysIP9rdZJ_zUjiem~"
  },
  ...
}

Now it’s time to install the NuGet package that communicates with the Azure app. It is called Microsoft.AspNetCore.Authentication.MicrosoftAccount; use the version matching your .NET version.

In Program.cs, look for the AddAuthentication() call; if it’s not there, add it. The NuGet package defined an extension method called AddMicrosoftAccount(). Provide the configuration options from the apsettings.json file in the following fashion:

builder.Services.AddAuthentication()
    .AddMicrosoftAccount(options =>
    {
        options.ClientId = builder.Configuration["MicrosoftAuthentication:AppID"];
        options.ClientSecret = builder.Configuration["MicrosoftAuthentication:Secret"];
    });

When you launch the application again and try to register an account, you will see that a Microsoft option has appeared (figure 12.17).

CH12_F17_Wenz

Figure 12.17 The user may now log in with a Microsoft account.

When you choose that option, you will be redirected to a Microsoft account login screen. After logging in, you need to give your consent for the application to receive your personal information (basically, the email address). Figure 12.18 shows that screen.

CH12_F18_Wenz

Figure 12.18 The web application wants to access Microsoft account data.

Once you agree, you will be redirected back to the application, where you may need to confirm your email address, and then you will be ready to use the application with a Microsoft account. You can then log into the web app using that account.

From a code perspective, the effort to implement this is quite limited, since the NuGet package takes care of most of the work. In the application itself, the first step is to call the SignInManager’s GetExternalAuthenticationSchemesAsync() method, which returns all external authentication services registered with the application. The login page uses that to decide which external authentication links to show (remember, figure 12.17 showed a link labeled “Microsoft”). Clicking that link sends an HTTP POST request to the ExternalLogin.cshtml page. Here, a redirect to the third-party authentication service’s login page is triggered:

public IActionResult OnPost(string provider, string returnUrl = null)
{
    var redirectUrl = Url.Page("./ExternalLogin", 
    pageHandler: "Callback", values: new { returnUrl });            
    var properties = _signInManager.ConfigureExternalAuthenticationProperties
    (provider, redirectUrl);                                        
    return new ChallengeResult(provider, properties);                 
}

Creates the URL the external authentication services redirect back to

Determines provider-specific properties

Prompts the provider to execute a redirect to the third-party site

In the Callback method (which kicks in once the third-party authentication site redirects back to the application), the central line of code is once again a call to the SignInManager:

var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor : true);

The ExternalLoginSignInAsync() method parses the data coming from the authentication service and can then decide whether the login was successful or not. If the user registers with the external account for the first time, they must register for a local account first (which will then be linked to the external account). Otherwise, the user will have immediate access to the application.

This was just one of over a dozen possible authentication services, but no matter which one you are using, ASP.NET Core Identity’s provider model does not require any major code changes to make that work, as long as there’s a NuGet package. You can find many more options at http://mng.bz/06BJ. The aspnet-contrib project contains many more NuGet packages for external authentication ready to be used in an ASP.NET Core app. Just search for “owners:aspnet-contrib title:OAuth” at http://mng.bz/j26a. As of the time of writing, there were over 80 matches.

Other types of authentication

Using individual user accounts is the most common authentication type for publicly available web applications. There are other options, too, especially for intranet and extranet applications. Here are a few alternative approaches as well as where to find more information on how to use them:

Summary

Let’s review what we have learned so far:

  • ASP.NET Core Identity is an API that can handle all aspects of user management and sign-in management.

  • ASP.NET Core comes with a full implementation for user self-management and sign-in management, but you can implement your own.

  • Via scaffolding, you can make both the UI and the logic of ASP.NET Core Identity features visible.

  • By default, passwords are stored securely (with a hash, using the best practices from chapter 8).

  • The [Authorize] attribute can be used to prevent unauthenticated users accessing pages of the web application.

  • Resource access may also be limited to certain roles, also using [Authorize].

  • Claims are name-value pairs containing information about a user. They may also be used for authorization.

  • After a configurable number of failed logins, an account can be locked for a certain amount of time.

  • ASP.NET Core Identity supports two-factor authentication (2FA). With little extra effort, the application can generate QR codes that facilitate onboarding of authenticator apps.

  • With the aid of specific NuGet packages, users can also register and authenticate with the web application using a third-party account from sites such as Facebook, Google, or Microsoft.

Sign-in management and user management are things that don’t always require a custom implementation from scratch—no need to reinvent the wheel every time. ASP.NET Core Identity provides everything required to allow users to log into an application and can be configured and extended to a great degree. For single-page applications (SPAs), some extra work is required. Chapter 13 has all the details.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset