This is my first article of 2023. I think it is the first in a series of articles about migrating an old ASP.net web app to Asp.net core on dotnet 7. Today, we will talk about migrating Old ASP.net authentication to Asp.net core identity with open id connect.
Recently, I’ve had to work on the migration of an ASP.net app that ran on dotnet 4.7, but was built back in 2011 with outdated libraries, but most importantly, an outdated authentication layer. This back end didn’t use ASP.net identity, it used “FormsAuthenticationTicket” which is something more outdated. Today, I will show you how to migrate an old authentication layer to ASP.net core identity with Open Id connect.
If you find this article useful, please follow me on Twitter, Github, Linkedin, or like my Facebook page to stay updated.Follow me on social media and stay updated
High-level concept
If you break things down to their basics, you will realize that any Authentication system simply relies on a database that has as primary table a “User” or “Accounts” table containing similar information for example, username, email, password, biography etc.… Taking this into consideration our aim here is to make every user of the outdated web app authenticate seamlessly on the new web app. Meaning that we should abstract the authentication process in such a way that OpenID connect and ASP.net Identity will replace the former authentication system without altering the end users experience.
Taking all the above into consideration, our first step is modifying the database model.
Upgrading the database schema
The former database schema used by the old authentication system must be updated to adapt itself to ASP.net core Identity. There are two ways to do this. This tutorial primarily focuses on migrating to ASP.net core Identity which can be used with open id connect, so open id connect isn’t really covered in this article.
NOTE: First test this in a dummy database before any updates to the production database.
- Either you do it via SQL commands (You add columns, manually to the already existing users table and create new tables for Roles, Claims etc.)
- You do a code first migration.
We will use the second approach since it is faster. For this, we will use Entity framework’s migration feature.
- In case you want to add open Id connect to your project, I recommend you use the server templates provided by Duende checkout this video: https://www.youtube.com/watch?v=eLRGlnGGUQs
- Choose the “isaspid” template if you want an already setup template with an initial ASP.net Identity configuration. To learn more about open Id connect with identity server, read this documentation: https://docs.duendesoftware.com/identityserver/v6
- In the new ASP.net core project, create an application user, with the properties required by the old authentication system. This class should inherit from the identity user.
Note: in our case, the former User database used the “integer” type as Id, whereas Asp.net core Identity uses strings by default. So, we create our custom entities as follows.
NOTE: Only follow the steps below If your previous user table had Ids as integers or a numeric type.
1 2 3 4 5 6 | public class ApplicationIdentityRole : IdentityRole<int> { public ApplicationIdentityRole() { } public ApplicationIdentityRole(string name) { Name = name; } } |
Then, we create our identity user.
1 2 3 4 5 6 7 | public class ApplicationUser: IdentityUser<int> { /// <summary> /// TODO: Add the properties of your former User (in the old ASP.net web app) /// </summary> } |
Then we create the db context to access the database.
1 2 3 | public class ApplicationDbContext: IdentityDbContext<ApplicationUser, ApplicationIdentityRole, int> {} |
- The next step is to inform entity framework that it should use the old “Users” table to store future users and retrieve previous users for authentication.
- And, in your old users table, you might have saved properties with names different from those used in Identity user. For example, in my database, the field UserName of my users was saved in the column “UName”, whereas in ASP.net core Identity, the column name is “UserName”. I need to tell entity framework about this difference. Open your DBcontext we created above and add the following lines.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.Entity<ApplicationUser>(b => { b.ToTable("users");//If the name of your old table is “users” b.Property(e => e.UserName).HasColumnName("UName"); //TODO: Tell entity framework all the naming differences that might exist between your database and the Identity models. }); } |
- Configure access to your database using its connection string as stated in the documentation: https://learn.microsoft.com/en-us/ef/core/miscellaneous/connection-strings
- Create your migrations using either your terminal or package manager console. Here is the command:
dotnet ef migrations add IdpMigration
- This will create a “Migrations” folder, with a class named “IdpMigration” or the name you gave to your migration.
- Inside the “up” method of this migration, if there is a “createTable” with “name:” being set to “users” or the name of your old users table in the database, remove that create statement.
- Add “update” statements to add to this old table the columns present in the “IdentityUser” but absent in your old users table. Some of these columns include
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | migrationBuilder.AddColumn<string>( name: "NormalizedEmail", type: "character varying(256)", maxLength: 256, table: "account", nullable: true); migrationBuilder.AddColumn<string>( name: "NormalizedUserName", type: "character varying(256)", maxLength: 256, table: "account", nullable: true); migrationBuilder.AddColumn<string>( name: "SecurityStamp", type: "text", table: "account", nullable: true); migrationBuilder.AddColumn<string>( name: "ConcurrencyStamp", table: "account", type: "text", nullable: true); migrationBuilder.AddColumn<bool>( name: "EmailConfirmed", table: "account", defaultValue: false, type: "boolean", nullable: false); migrationBuilder.AddColumn<bool>( name: "PhoneNumberConfirmed", table: "account", defaultValue: false, type: "boolean", nullable: false); migrationBuilder.AddColumn<bool>( name: "AccessFailedCount", table: "account", type: "integer", defaultValue: false, nullable: false); migrationBuilder.AddColumn<DateTimeOffset>( name: "LockoutEnd", type: "timestamp with time zone", table: "account", nullable: true); migrationBuilder.AddColumn<bool>( name: "LockoutEnabled", table: "account", defaultValue: false, type: "boolean", nullable: false); migrationBuilder.AddColumn<bool>( name: "TwoFactorEnabled", table: "account", defaultValue: false, type: "boolean", nullable: false); |
- Run the migration with the command “dotnet ef database update SecondMigration5”
- This will add new columns to your users table, and create new tables in your database for user roles, claims etc.
- Once you are done, your database should be ready. To test this, create a new user using the UserManager. To do this, at your server’s startup, you could run this demo code to create a new user.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>(); var alice = userMgr.FindByNameAsync("alice").Result; if (alice == null) { alice = new ApplicationUser { UserName = "alice", Email = "AliceSmith@email.com", EmailConfirmed = true, }; var result = userMgr.CreateAsync(alice, "Pass123$").Result; if (!result.Succeeded) { throw new Exception(result.Errors.First().Description); } result = userMgr.AddClaimsAsync(alice, new Claim[]{ new Claim(JwtClaimTypes.Name, "Alice Smith"), new Claim(JwtClaimTypes.GivenName, "Alice"), new Claim(JwtClaimTypes.FamilyName, "Smith"), new Claim(JwtClaimTypes.WebSite, "http://alice.com"), }).Result; if (!result.Succeeded) { throw new Exception(result.Errors.First().Description); } Log.Debug("alice created"); } |
- If it fails, patiently inspect the errors messages, and correct them. These will most likely be errors related to already existing constraints on the database that are not respected by your models.
- Once the user can be created without any issue, in case you used the open id connect template I precise earlier, run the project and try authenticating with the user credential you just created. In this step, you might still have errors due to constraints, but they will be minor errors.
Making your old users compatible to ASP.net identity
Our database schema has been updated, we can authenticate newly created users, but what about old users of the former web app?. They will not be able to authenticate using ASP.net Identity even if they are present in the database. This is because the UserManager’s findbyemail or username won’t return these old users no matter how many times you try. To make your old users compatible, follow these steps.
- Run through every row of the users table in your database (Only rows for old users that where not ceated with ASP.net identity’s usermanager).
- For each user, apply the following computations. And add the appropriate claims to your users in the database. You might do this with a background job on your servers, or something similar.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | foreach (var user in users) { var result = await _userManager.UpdateSecurityStampAsync(user); if (result != null && !result.Succeeded) { throw new Exception($"Failed to make user: {user.Id} Compatible. Error: {result.Errors.First().Description}"); } await _userManager.UpdateNormalizedEmailAsync(user); await _userManager.UpdateNormalizedUserNameAsync(user); result = await _userManager.SetLockoutEnabledAsync(user, true);//Either true or false, depending on your usecase if (result != null && !result.Succeeded) { throw new Exception($"Failed to make user: {user.Id} Compatible. Error: {result.Errors.First().Description}"); } //Add every required claim too for each user. result = _userManager.AddClaimsAsync(user, new Claim[]{ new Claim(JwtClaimTypes.Name, user.name ).Result; if (!result.Succeeded) { throw new Exception(result.Errors.First().Description); } } |
Once you run this, fields required to make users authenticate with ASP.net identity will be set in the database. You will then be able to load users with ASP.net identity.
Password hash and validation
In case your former authentication system used a different method to hash and compare passwords, you need to inform ASP.net identity about this. Else, your old users’ passwords won’t be recognized by the system.
- To do this, locate your old algorithm to hash passwords and create a new “PasswordHasher” that will combine your old password validation with the new most recent one provided by Microsoft, with all security updates and patches. Here is the password hasher:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | public class YSPasswordHasher : IPasswordHasher<ApplicationUser> { //an instance of the default password hasher used by Asp.net core identity IPasswordHasher<ApplicationUser> _defaultPasswordHasher; public YSPasswordHasher(IOptions<PasswordHasherOptions>? optionsAccessor = null) { _defaultPasswordHasher = new PasswordHasher<ApplicationUser>(optionsAccessor); } internal string HashPasswordWithOldAlgorithm(string password) { var sha1 = SHA1.Create(); // TODO: Add your old password hashing algorithm here return s.ToString(); } public string HashPassword(ApplicationUser user, string password) { return _defaultPasswordHasher.HashPassword(user, password); } public PasswordVerificationResult VerifyHashedPassword(ApplicationUser user, string hashedPassword, string providedPassword) { string pwdHash2 = HashPasswordWithOldAlgorithm(providedPassword); //Depending on the password format, one of the hashes should work. Either the old or the new password. if (hashedPassword == pwdHash2) { return PasswordVerificationResult.Success; } else { return _defaultPasswordHasher.VerifyHashedPassword(user, hashedPassword, providedPassword); } } |
- Then, Tell Asp.net core to use your new password hasher that is compatible with new and old users. Do this by registering the service using DI as follows:
1 | builder.Services.AddSingleton<IPasswordHasher<ApplicationUser>, YSPasswordHasher>(); |
Conclusion
With the above, you should be able to migrate smoothly to ASP.net identity. When I did this at first, the process was easier than expected proving how flexible ASP.net core is. I’ll be posting about Web3 very soon, If your interested in crypto, here is an article I wrote about end-to-end encryption with RSA.
Follow me on social media and stay updated
Ashish Khanal
Damien Doumer
ayc
Damien Doumer