Upgrading to Optimizely 12 ASP.NET Core Identity

D e v e l o p m e n t , O p t i m i z e l y ( E p i s e r v e r )
Sven-Erik Jonsson 1/31/2022 11:59:14 AM

Migrating from ASP.NET Membership to ASP.NET Identity

The process of upgrading from membership has largely been detailed in this MSDN article and can be used as a reference if coming directly from legacy handling. We did this prior to upgrading. Albeit causing a bit of extra work, it was easier to break the work into actionable work items.

In addition to describing the required data model changes this article contains the source code to a legacy hashing algorithm (SQLPasswordHasher) used when hashing passwords. Take note of this as it is a useful resource for regenerating user password hashes later.

For brevity this upgrade process is henceforth considered out of scope.

Migrating from ASP.NET Identity to ASP.NET Core Identity

Due to the similar naming and scope of these packages one might make the mistake that they are compatible to a certain degree. They are not, out of the box.

Key differences

Replaced data layer

While EPiServer.CMS.UI.AspNetIdentity for version 11 uses EntityFramework, the package for version 12 uses Microsoft.EntityFrameworkCore, which contains fundamental changes. For example, where Entity Framework has automatic migrations, EFCore only has explicit migrations or that Entity Framework stores migrations in the table __MigrationHistory, EFCore in __EFMigrationsHistory.

These changes propagate all the way down through the dependency tree and lead to small differences in the implementation of seemingly benign things like the max string lengths of the user table.

Different password hashing algorithm

The default implementation of IPasswordHasher is different between the two packages, which needs to be addressed in order for existing users to log in to the system. For this we took a similar approach as the MSDN article detailing SQL Membership migration, providing fallback handling for old hashes.

Migration steps

The steps detailed below require that the upgrade assistant has been run and that the solution builds using dotnet build from the CLI and that the standard tables like AspNetUsers are present and populated in your database. Additionally Entity Framework Core .NET Command-line Tools need to be installed, guide here. It is also a really good idea to back up your database containing users at this point.
Note that I’ve modified namespaces and generalized the code a bit below. Please provide feedback if you don’t get something to work.

Override the default Optimizely ApplicationBuilderExtensions

Unfortunately the current helpers for setting up AspNetIdentity on Optimizely do not take migrations into account so some extra hoops have to be jumped through in order to be able to control the migrations assembly for the ApplicationDbContext. Hopefully someone at the company reads this and adds an overload for migrations.
Additional ApplicationBuilderExtensions for AspNetCore Identity in Optimizely 12

Even more unfortunately, the default implementation of AspNetIdentitySecurityEntityProvider used in the Optimizely implementation is internal and cannot be reused. Here is an implementation that I wrote to replace it you can use instead. The same goes for ApplicationOptionsPostConfigurer and AspNetIdentitySchemaUpdater. Sidenote… I find that for a company that develops codebase that is supposed to be used by other developers, there sure are a lot of private and internal implementations.

Register the extension in Startup.cs

public class Startup
{
   public void ConfigureServices(IServiceCollection services)
   {
     ...
     services.AddCmsAspNetIdentity<YourApplicationUser>(options =>
     {
         options.ConnectionStringOptions = commerceConnection;
     },
     null, // Override identity defaults, such as password length or content requirements.
     null, // Add additional services.
     builder =>
         builder.MigrationsAssembly("YourProject.AssemblyName"));
     ...
   }
}

Add a default migration

The default data tables need to be changed in order for the solution to run. To do this we can apply some basic commands.
Use dotnet ef dbcontext list to get the name of the context that is registered in startup.

Use dotnet ef migrations add Net5Upgrade --context "{context name from previous command}" to create a migration file.

Replace the contents of the up and down methods with the following.

using System;
using Microsoft.EntityFrameworkCore.Migrations;

namespace YourProject.Migrations
{
   public partial class Net5Upgrade : Migration
   {
       protected override void Up(MigrationBuilder migrationBuilder)
       {
           migrationBuilder.AddColumn<string>("NormalizedUserName", "AspNetUsers", "nvarchar(256)", unicode: true, maxLength: 256, nullable: true);
           migrationBuilder.AddColumn<string>("NormalizedEmail", "AspNetUsers", "nvarchar(256)", unicode: true, maxLength: 256, nullable: true);
           migrationBuilder.AddColumn<DateTimeOffset>("LockoutEnd", "AspNetUsers", "datetimeoffset", nullable: true);
           migrationBuilder.AddColumn<string>("ConcurrencyStamp", "AspNetUsers", "nvarchar(256)", unicode: true, maxLength: 256, nullable: true);

           migrationBuilder.Sql(@"UPDATE [dbo].[AspNetUsers]
                                  SET [NormalizedUserName] = UPPER([UserName])
                                  WHERE [NormalizedUserName] IS NULL");

           migrationBuilder.AddColumn<string>("NormalizedName", "AspNetRoles", "nvarchar(256)", unicode: true, maxLength: 256, nullable: true);
           migrationBuilder.AddColumn<string>("ConcurrencyStamp", "AspNetRoles", "nvarchar(256)", unicode: true, maxLength: 256, nullable: true);
           migrationBuilder.Sql(@"UPDATE [dbo].[AspNetRoles]
                                  SET [NormalizedName] = UPPER([Name])
                                  WHERE [NormalizedName] IS NULL");

           migrationBuilder.AddColumn<string>("ProviderDisplayName", "AspNetUserLogins", "nvarchar(max)", unicode: true, nullable: true);
           migrationBuilder.CreateTable(
               name: "AspNetRoleClaims",
               columns: table => new
               {
                   Id = table.Column<int>(type: "int", nullable: false)
                       .Annotation("SqlServer:Identity", "1, 1"),

                    RoleId = table.Column<string>(type: "nvarchar(128)", nullable: false),
                   ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
                   ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
               },
               constraints: table =>
               {
                   table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
                   table.ForeignKey(
                       name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
                       column: x => x.RoleId,
                       principalTable: "AspNetRoles",
                       principalColumn: "Id",
                       onDelete: ReferentialAction.Cascade);
               });

            migrationBuilder.CreateIndex(
               name: "IX_AspNetRoleClaims_RoleId",
               table: "AspNetRoleClaims",
               column: "RoleId");

            migrationBuilder.CreateTable(
               name: "AspNetUserTokens",
               columns: table => new
               {
                   UserId = table.Column<string>(type: "nvarchar(128)", nullable: false),
                   LoginProvider = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
                   Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
                   Value = table.Column<string>(type: "nvarchar(max)", nullable: true)
               },
               constraints: table =>
               {
                   table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
                   table.ForeignKey(
                       name: "FK_AspNetUserTokens_AspNetUsers_UserId",
                       column: x => x.UserId,
                       principalTable: "AspNetUsers",
                       principalColumn: "Id",
                       onDelete: ReferentialAction.Cascade);
               });
       }

       protected override void Down(MigrationBuilder migrationBuilder)
       {
           migrationBuilder.DropColumn("NormalizedUserName", "AspNetUsers");
           migrationBuilder.DropColumn("NormalizedEmail", "AspNetUsers");
           migrationBuilder.DropColumn("LockoutEnd", "AspNetUsers");
           migrationBuilder.DropColumn("ConcurrencyStamp", "AspNetUsers");
           migrationBuilder.DropColumn("NormalizedName", "AspNetRoles");
           migrationBuilder.DropColumn("ConcurrencyStamp", "AspNetRoles");
           migrationBuilder.DropColumn("ProviderDisplayName", "AspNetUserLogins");

           migrationBuilder.DropTable("AspNetRoleClaims");
           migrationBuilder.DropTable("AspNetUserTokens");
       }
   }
}

Register the migration to run on startup

public class Startup
{
   public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory)
   {
       ...
       InitializeDatabaseSchema<ApplicationDbContext<YourApplicationUser>>(app);
   }

   private void InitializeDatabaseSchema<TContext>(IApplicationBuilder app)
     where TContext : DbContext
   {
       var factory = app.ApplicationServices.GetService<IServiceScopeFactory>();

       using var scope = factory.CreateScope();
       using var context = scope.ServiceProvider.GetRequiredService<TContext>();

       context.Database.Migrate();
   }
}

Implement and register fallback IPasswordHasher

A password hasher featuring fallback can be implemented like this.
The legacy algorithm is found inside Microsoft.AspNet.Identity.Core.dll can be found here.

Register the hasher implementation in Startup.cs

public class Startup
{
   public void ConfigureServices(IServiceCollection services)
   {
     ...
     services.AddTransient<IPasswordHasher<YourApplicationUser>, FallbackPasswordHasher<YourApplicationUser>>();
     ...
   }
}

You should now be able start the application and authenticate using existing users.