ASP.NET Core 3.1 - Cookie Validator

This article will demonstrate the implementation of verifying the authentication cookie on every request. I will assume you have downloaded the ASP.NET Core 3.1 - Users Without Identity Project or created a new ASP.NET Core 3.1 Razor Pages project. See Tutorial: Get started with Razor Pages in ASP.NET Core. You should review the earlier articles of the Users Without Identity Project series.

Users Without Identity Project and Article Series

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 02/23/2021

I added the Enhanced User Series' article links.

Ken Haggerty
Created 02/02/20
Updated 02/24/21 00:15 GMT

Log In or Reset Quota to read more.

Successfully completed. Thank you for contributing.
Processing...
Something went wrong. Please try again.
Contribute to enjoy content without advertisments.
You can contribute without registering.

Comments(0)

Loading...
Loading...

Not accepting new comments.

Submit your comment. Comments are moderated.

User Image.
DisplayedName - Member Since ?