Skip to content

Master ASP.NET Core Identity in .NET10: Auth & Authorization - Deep Dive

Tutor: Frank Liu

Course Link

The Three Essential Parts of Identity

ASP.Net Core identity is a membership systems that help us do what we did manually in the previous section.

flowchart LR
    usr[User]
    bsr[Browser]
 subgraph Abstraction["Web Server"]
    Vc["Verify Credentials"]
    Sc["Generate Security Context"]
    Au[Authentication]
    Az[Authorization]
 end
    Db[("Data Store")]
    usr -- 1 -->bsr
    bsr -- 2 --> Vc
    Vc -- 3 --> Db
    Db -- 4 --> Sc
    Sc -- 5 --> bsr
    bsr -- 6--> Au
    Au -- 7 --> Az
    Az -- 8 --> bsr

These are the three essential parts of ASP.Net Core identity:

  1. UI
  2. Browser
  3. Functionalities
  4. Everything in the Web Server
  5. Data Store
  6. Where User information and claims are stored

We need to add a new project to demonstrate this.


Note: Blazor authentication can be different to traditional web applications.


Add to the solution the following project based on the ASP.NET Core Web App (Razor Pages) template:

  • Project Name: WebApp
  • Framework: .Net 10
  • Authentication Type: None

Create with the rest as defaults.

We will need to install the following Nuget packages:

  • Microsoft.AspNetCore.Identity.UI for the UI
  • ~~Microsoft.AspNetCore.Identity~~ for the functionalities (not needed as already included)
  • Microsoft.AspNetCore.Identity.EntityFrameworkCore for the Data Store

We will also need

  • Microsoft.EntityFrameworkCore.SqlServer to write to the data store
  • Microsoft.EntityFrameworkCore.SqlServer.Design
  • Microsoft.EntityFrameworkCore.SqlServer.Tools

Microsoft.AspNetCore.Identity.UI contains all the scaffolded user interfaces, which are comprehensive and cover most scenarios. This section will not use them and create everything from scratch. This would be used in the real world.

We start by creating our in-memory representation of our identity database, then we will run our migration to actually create the identity database in SQL server.

Create the root folder /Data in the application and add to this a class to represent the in-memory representation of our identity database called ApplicationDbContext.

This class derives from IdentityDbContext which already contains all the Db tables we will need, for example IdentityUser and IdentityRole. The work we need to do just adds to this:

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace WebApp.Data;

public class ApplicationDbContext : IdentityDbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options): base(options)
    {

    }
}

This is going to contain all the database tables that represents a core identity.

Now we need to set up DI Injection and configure the connection to an actual database in the WebApp Program.cs file before builder.Build() is called:

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

Also setup the connection string in appsettings.development.json (or the main file):

"ConnectionStrings": {
  "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Initial Catalog=UdemySecurityCourse;Integrated Security=True;Trusted_Connection=True;TrustServerCertificate=True;"
}

NOTE: You can add a connection via Visual Studio's Server Explorer pane and copy the details from there if it helps.

Server Explorer Connection Example


We can now run database migration to actually create the database. Set WebAPI as the startup project, then in Package Manager Console with the default project set as WebApp:

> Add-Migration Initial
> Update-Database

Configure WebApp to Use Identity

Now that we have created our identity database, we can update Program.cs to configure identity.

We are trying to use identity to protect our web application, which means that we need to use the authentication middleware to implement this type of protection.

First add app.UseAuthentication() just before the app.UseAuthorization() call, as we did before (remember, we Authenticate before we Authorise).

Then earlier in the file before the builder.Build() call we add the dependency injection to configure the authentication middleware to add identity to the workflow of the authentication middleware.

builder.Services.AddIdentity<IdentityUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

At the moment we will use the default IdentityUser and IdentityRole classes, but we can (and usually will) subclass these with additional fields required for our applications, so these types will be substitutes for the updated ones as and when necessary.

The AddEntityFrameworkStores<ApplicationDbContext>() tells the identity system that this is the database it needs to connect to in order to work with the user data.

With the framework in place we can now configure the behaviour of the identity system by updating the above code:

builder.Services.AddIdentity<IdentityUser, IdentityRole>(options =>
{
    options.Password.RequiredLength = 8;
    options.Password.RequireLowercase = true;
    options.Password.RequireUppercase = true;

    options.Lockout.MaxFailedAccessAttempts = 5;
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);

    options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>();

Then configure the cookie behaviour, similar to as we did previously, but not quite the same, after the AddIdentity configuration:

builder.Services.ConfigureApplicationCookie(options =>
{
    options.LoginPath = "/Account/Login";
    options.AccessDeniedPath = "/Account/AccessDenied";
});

Core Identity Classes

There are two main classes to work with in ASP.NET security:

  1. The SignInManager which helps us to verify credentials and create security contexts and thus the authentication.
  2. The UserManager which help us to retrieve all of the user information stored in the database.

For authorization, we still have to create our own policies and we need custom authorization handlers.

User Registration Workflow

User registration works like this:

sequenceDiagram
    actor User
    User ->> +Identity: Register
    Identity ->> +Database: Create the User
    Database ->> -Identity: User created
    Identity ->> -User: Send Email
    User ->> +Identity: Verifies the Email
    Identity ->> Identity: Validates the link
    Identity ->> Database: Update user record as validated
    Identity ->> -User: Return Success

User Registration

Start be creating a User Registration Page. Right-click on the Pages folder, Add an Account folder in the Solution Explorer, then on this and select Add Razor Page.... Create an empty razor page called Register. Create a class for the ViewModel (in the cshtml.cs file as is standard):

public class RegisterViewModel
{
    [Required]
    [EmailAddress]
    [Display(Name = "Email")]
    public string Email { get; set; } = string.Empty;

    [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; } = string.Empty;

    [DataType(DataType.Password)]
    [Display(Name = "Confirm password")]
    [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
    public string ConfirmPassword { get; set; } = string.Empty;
}

Add this to the PageModel as a bind-able class:

public class RegisterModel : PageModel
{
    [BindProperty]
    public RegisterViewModel Input { get; set; } = new RegisterViewModel();

    public void OnGet()
    {
    }
}

Then edit the HTML markup:

@page
@model WebApp.Pages.RegisterModel
@{
}

<h3>User Registration</h3>
<br />
<div class="container border" style="padding: 20px;">

    <form method="post">
        <div class="text-danger" asp-validation-summary="All"></div>
        <div class="row mb-3">
            <div class="col-2">
                <label asp-for="RegisterViewModel.Email" class="form-label"></label>
            </div>
            <div class="col-5">
                <input type="text" asp-for="RegisterViewModel.Email" class="form-control" />
                <span class="text-danger" asp-validation-for="RegisterViewModel.Email"></span>
            </div>
        </div>
        <div class="row mb-3">
            <div class="col-2">
                <label asp-for="RegisterViewModel.Password" class="form-label"></label>
            </div>
            <div class="col-5">
                <input type="password" asp-for="RegisterViewModel.Password" class="form-control" />
                <span class="text-danger" asp-validation-for="RegisterViewModel.Password"></span>
            </div>
        </div>
        <div class="row mb-3">
            <div class="col-2">
                <label asp-for="RegisterViewModel.ConfirmPassword" class="form-label"></label>
            </div>
            <div class="col-5">
                <input type="password" asp-for="RegisterViewModel.ConfirmPassword" class="form-control" />
                <span class="text-danger" asp-validation-for="RegisterViewModel.ConfirmPassword"></span>
            </div>
        </div>
        <div class="row mb-3">
            <div class="col-2">
                <button type="submit" class="btn btn-primary">Register</button>
            </div>
            <div class="col-5">
            </div>
        </div>
    </form>
</div>

We can now test this page by manually entering /account/register to the base URL in the browser when we run, this should display correctly and the validation should appear properly when we hit the Register button.

Back in the PageModel code behind we will want access to the user manager, so create a constructor so this can be injected:

    private readonly UserManager<IdentityUser> _userManager;

    public RegisterModel(UserManager<IdentityUser> userManager)
    {
        _userManager = userManager;
    }

Now we can add a post method to process the input:

public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid)
        return Page();

    // Validate the Email address does not exist (optional as we added a check)

    // Create the user
    var user = new IdentityUser
    {
        UserName = RegisterViewModel.Email,
        Email = RegisterViewModel.Email
    };

    var result = await _userManager.CreateAsync(user, RegisterViewModel.Password);
    if (!result.Succeeded)
    {
        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
        return Page();
    }

    return RedirectToPage("/Account/Login");
}

User Login

The login page is similar to the one made in the previous section, so this can be copied across to /Account with a few changes as detailed:

Login.cshtml:

  • Namespace changed
  • Fields for Credential object changed (e.g. UserName -> Email)
  • Added a title
  • Add a 'Sign up' Link.
@page
@model WebApp.Pages.Account.LoginModel
@{
}

<br />

<div class="container border" style="padding: 20px;">

    <form method="post">
        <div class="text-danger" asp-validation-summary="All"></div>
        <div class="row mb-3">
            <div class="col-2">
                <label asp-for="Credential.Email" class="form-label"></label>
            </div>
            <div class="col-5">
                <input type="text" asp-for="Credential.Email" class="form-control" />
                <span class="text-danger" asp-validation-for="Credential.Email"></span>
            </div>
        </div>
        <div class="row mb-3">
            <div class="col-2">
                <label asp-for="Credential.Password" class="form-label"></label>
            </div>
            <div class="col-5">
                <input type="password" asp-for="Credential.Password" class="form-control" />
                <span class="text-danger" asp-validation-for="Credential.Password"></span>
            </div>
        </div>
        <div class="row mb-3 form-check">
            <div class="col-2">
                <input type="checkbox" asp-for="Credential.RememberMe" class="form-check-input" />
                <label asp-for="Credential.RememberMe" class="form-check-label"></label>
            </div>
            <div class="col-5">
            </div>
        </div>
        <div class="row mb-3">
            <div class="col-2">
                <button type="submit" class="btn btn-primary">Login</button>
            </div>
            <div class="col-5">
                <a accesskey="R" class="btn btn-link" asp-page="/Account/Register">Register</a>
            </div>
        </div>
    </form>
</div>

Login.cshtml.cs:

  • Namespace changed
  • Removed implementation code in OnPostAsync as this will change
  • Recreated an updated Credential class with the name and references updated to CredentialViewModel class
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;

namespace WebApp.Pages.Account
{
    public class LoginModel : PageModel
    {
        [BindProperty]
        public CredentialViewModel Credential { get; set; } = new CredentialViewModel();

        public void OnGet()
        {
        }

        public async Task<IActionResult> OnPostAsync()
        {
        }
    }

    public class CredentialViewModel
    {
        [Required]
        [EmailAddress]
        public string Email { get; set; } = string.Empty;

        [Required]
        [DataType(DataType.Password)]
        public string Password { get; set; } = string.Empty;

        [Display(Name = "Remember Me")]
        public bool RememberMe { get; set; }
    }
}

Now we need to implement the the login functionality. We will need a reference to the SignInManager so inject this via the constructor:

private readonly SignInManager<IdentityUser> _signInManager;

...

public LoginModel(SignInManager<IdentityUser> signInManager)
{
    _signInManager = signInManager;
}

Then Implement the OnPostAsync() method:

public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

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

    if (result.Succeeded)
    {
        return RedirectToPage("/Index");
    }

    if (result.IsLockedOut)
    {
        ModelState.AddModelError("Login", "You are locked out.");
    }
    else
    {
        ModelState.AddModelError("Login", "Failed to login.");
    }

    return Page();
}
}

The user we created does not yet have their email confirmed, we can check for this by setting SignIn.RequireConfirmedEmail in the options section of Program.cs:

builder.Services.AddIdentity<IdentityUser, IdentityRole>(options =>
{
    options.Password.RequiredLength = 8;
    options.Password.RequireLowercase = true;
    options.Password.RequireUppercase = true;

    options.Lockout.MaxFailedAccessAttempts = 5;
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);

    options.User.RequireUniqueEmail = true;
    options.SignIn.RequireConfirmedEmail = true;
})

Now when we run this and try to login we get the "Failed to Login" error displayed due to the password confirmation.

Email Confirmation

In the verify email we send a token. The token is generated, saved to the user record and sent to the user in an email as a link parameter.

When the user clicks the link an endpoint (page) is accessed on the website, this checks the supplied token with the one in the database, and if they match the user email is recorded as verified.

However, you can have a token that can do self validation like the JWT token does. Identity uses this approach.


How identity generates and validates the token

The token is not actually stored in the in the data store. It basically just generates a token by hashing some part of the user information that is unique to that user and then that token is sent to the email.

When the user clicks on the link in the email, the token will be sent to the server to be validated. The User ID is included with this request.

When the server validates the token, it takes the user ID, it retrieves the same information that it was used to generate the token and then it will try to generate another token. Because it uses the same user information (hashing algorithm and key), then it will always generate the same token, which can then be used to compare with the token in the url contained in the emailed link.


Back in Register.cshtml.cs the OnPostAsync method redirects to the login page on successful registration, but the correct flow is that we need to send an email notification to the user along with the confirmation token in a link. We need to update the success section as follows:

// Successful registration
//Generate a token and send email confirmation
var confirmationToken = await _userManager.GenerateEmailConfirmationTokenAsync(user);
// TODO: Send the token via email to the user with a confirmation link
var confirmationPage = Url.PageLink(
    pageName: "/Account/ConfirmEmail",
    values: new { userId = user.Id, token = confirmationToken }
    );
return Redirect(confirmationPage ?? "");

Before we can test this we have to set up the token provider in Program.cs by chaining it to the AddIdentity method call:

builder.Services.AddIdentity<IdentityUser, IdentityRole>(options =>
{
    ...
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();

This adds the default token provider, but you could specify other providers.

You cannot run this yet as Url.PageLink(...) will return null until the page actually exists, so create this page as an empty Razor page.

The new page will have to read the two parameters we sent to it above (userId and token) which is done in the usual way by adding the parameters to the OnGetAsync signature. We will also need to inject the UserManager again.

ConfirmEmail.cshtml.cs:

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace WebApp.Pages.Account;

public class ConfirmEmailModel : PageModel
{
    [BindProperty]
    public string Message { get; set; } = string.Empty;

    private readonly UserManager<IdentityUser> _userManager;

    public ConfirmEmailModel(UserManager<IdentityUser> userManager)
    {
        _userManager = userManager;
    }

    public async Task<IActionResult> OnGetAsync(string userId, string token)
    {
        var user = await _userManager.FindByIdAsync(userId);
        if (user != null)
        {
            var result = await _userManager.ConfirmEmailAsync(user, token);
            if (result.Succeeded)
            {
                Message = "Email confirmed successfully. You may now login.";
                return Page();
            }
        }

        Message = "Email validation failed.";
        return Page();
    }
}

And update the page markup to display the message field, ConfirmEmail.cshtml:

@page
@model WebApp.Pages.Account.ConfirmEmailModel
@{
}

<p>
    @Model.Message
</p>

We can now test this by registering a new user, which will redirect to the ConfirmationEmail page, which automatically confirms the email and displays a user on the screen. If you look at the field EmailConfirmed in the [AspNetUsers] table for this user, you will see it is now set at 1 (true).

It should now also be possible to log into the web app.

Video 35