ASP.NET Core 3.1 - Cookie Validator


Ken Haggerty
Created 02/02/2020 - Updated 04/24/2020 15:09

This article will demonstrate the implementation of verifying the authentication cookie on every request. I will assume you have created a new ASP.NET Core 3.1 Razor Pages project and have implemented the first 5 articles of the series. See Tutorial: Get started with Razor Pages in ASP.NET Core .

Users Without Identity Project and Article Series


Access to the research project source code may be purchased on KenHaggerty.Com at Manage > Assets.

A project which implements users without Identity has been published to demo.kenhaggerty.com.

I enjoy writing these articles. It often enhances and clarifies my coding. The research project is a result of a lot of refactoring and hopefully provides logical segues for the articles. Thank you for supporting my efforts.

Once a cookie is created, the cookie is the single source of identity. If a user account is disabled in back-end systems:
  • The app's cookie authentication system continues to process requests based on the authentication cookie.
  • The user remains signed into the app as long as the authentication cookie is valid.
The ValidatePrincipal event can be used to intercept and override validation of the cookie identity. Validating the cookie on every request mitigates the risk of revoked users accessing the app.

Most examples use LastChanged as string to compare the cookie to the user record stored in the database. The LastChanged property should be updated every time the user is edited. We can use a RowVersion byte[] property with the [TimeStamp] data annotation which will automatically update on SQL Insert and SQL Update.

Edit Entities > AppUser.cs:
public class AppUser
{
    [Key]
    public int Id { get; set; }

    [Required]
    [Display(Name = "Login Name")]
    [StringLength(32, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
    public string LoginName { get; set; }

    [Required]
    [StringLength(32)]
    public string LoginNameUppercase { get; set; }

    [Required]
    [MaxLength(84, ErrorMessage = "The {0} is max {1} characters long.")]
    public string PasswordHash { get; set; }

    [Display(Name = "Admin")]
    public bool IsAdmin { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; }
}

Add the RowVersion migration.

Package Manager Console:
add-migration RowVersion -o Data/Migrations
DotNet CLI:
dotnet-ef migrations add RowVersion -o Data/Migrations

The add migration produces a MigrationBuilder which will add the RowVersion column to the AppUsers table. Notice rowVersion: true. The file name prepends a date time stamp to RowVersion.

20200120183748_RowVersion.cs:
public partial class RowVersion : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<byte[]>(
            name: "RowVersion",
            table: "AppUsers",
            rowVersion: true,
            nullable: true);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(
            name: "RowVersion",
            table: "AppUsers");
    }
}

If all looks good, update the database.

Package Manager Console:
update-database
DotNet CLI:
dotnet-ef database update

We need to convert the RowVersion byte[] property to string to store the value in a claim. Add a UserData claim = rowVersionString to the Claims Principle at login. Be sure to add the NameIdentifier claim.

Edit Account/ Login.cshtml.cs > OnPost:
string rowVersionString = Encoding.Unicode.GetString(user.RowVersion);

var claims = new List<Claim>
{
    new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(), ClaimValueTypes.Integer),
    new Claim(ClaimTypes.Name, user.LoginName)
    new Claim(ClaimTypes.UserData, rowVersionString)
};

Create a new class named CookieValidator in the Services folder.

Services/ CookieValidator.cs:
public static class CookieValidator
{
    public static async Task ValidateAsync(CookieValidatePrincipalContext context)
    {
        if (!await ValidateCookieAsync(context))
        {
            context.RejectPrincipal();
            await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        }
    }

    private static async Task<bool> ValidateCookieAsync(CookieValidatePrincipalContext context)
    {
        var claimsPrincipal = context.Principal;

        var uid = (from c in claimsPrincipal.Claims
                    where c.Type == ClaimTypes.NameIdentifier
                    select c.Value).FirstOrDefault();

        if (!int.TryParse(uid, out int userId))
        {
            return false;
        }

        var rowVersionString = (from c in claimsPrincipal.Claims
                                    where c.Type == ClaimTypes.UserData
                                    select c.Value).FirstOrDefault();

        if (string.IsNullOrEmpty(rowVersionString))
        {
            return false;
        }

        byte[] rowVersion = Encoding.Unicode.GetBytes(rowVersionString);

        var applicationDbContext = context.HttpContext.RequestServices.GetRequiredService<ApplicationDbContext>();

        return await applicationDbContext.AppUsers
            .AsNoTracking()
            .Where(a => a.Id == userId)
            .Select(a => a.RowVersion.SequenceEqual(rowVersion))
            .FirstOrDefaultAsync()
            .ConfigureAwait(false);
    }
}

Configure the OnValidatePrincipal in the CookieAuthenticationEvents options to call the CookieValidator. ValidateAsync function.

Edit Startup.cs > ConfigureServices:
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Events = new CookieAuthenticationEvents
        {
            OnValidatePrincipal = CookieValidator.ValidateAsync
        };
    });

Build, run and test. Set a breakpoint on the CookieValidator. ValidateAsync function. Notice the breakpoint is only hit when a user is signed in. If the cookie doesn't validate the user is signed out. To test, you can use 2 browsers like Chrome and Firefox. Login to one as Administrator and login to the other as a different user. Edit and update the other user with Administrator. Refreshing the page for the other user will sign out the other user.

Update 04/24/2020

I published the User Database Service article and updated the article links. I implemented a UserService in the Users Without Identity Project at v1.0.6 and have updated the project's NuGet packages, details and download.



Comment Count = 0

Please log in to comment or follow.

Login Register
Follow to get web notifications when new comments are posted to this article.
Logged in users receive web notifications for new articles, topics and assets.
Web Notifications