Upgrade to ASP.NET Identity

For our first post, we'll dive into some technical challenges we encountered when upgrading our Authentication.

Our site was using a custom Forms Authentication implementation before Microsoft released ASP.NET Identity 2.0. Since we recently upgraded our solution to MVC 5, we took the next step of upgrading the authentication components and benefit from the simplified 3rd party integration. If you happen to face a similar situation, use the steps below as a guideline to help you complete the upgrade

Hope this helps!


Assumptions

  • You're using Forms Authentication with ASP.NET
  • You've upgraded your web solution to ASP.NET MVC5+
  • You have a custom USER table with an Id of type integer


Outline

  • Database
    • Add new columns to the USER table
    • Add new tables
    • Update EDMX or Code-First POCO for USER table only
  • Packages
    • Add nuget packages to your solution
  • Configuration
    • Add a NEW database connection string to Web.Config
    • Remove Forms Authentication & Membership sections
    • (optional) Remove DotNetOpenAuth in case you were using it
  • Code
    • Add new identity folder/classes
    • Setup your Startup classes
    • (optional) Setup Unity IoC
    • Create a separate default ASP.NET MVC 5 solution
    • Manage Account ViewModels
    • Manage Account Controller
    • Manage Account Views
  • Tips

Database 

  • Add new columns to the USER table
ALTER Table [dbo].[USER] ADD  
   [EmailConfirmed] [bit] NOT NULL  DEFAULT ((1)),
   [SecurityStamp] [nvarchar](max) NULL,
   [PhoneNumber] [nvarchar](50) NULL,
   [PhoneNumberConfirmed] [bit] NOT NULL DEFAULT ((0)),
   [TwoFactorEnabled] [bit] NOT NULL DEFAULT ((0)),
   [LockoutEndDateUtc] [datetime2](7) NULL,
   [LockoutEnabled] [bit] NOT NULL DEFAULT ((0)),
   [AccessFailedCount] [int] NOT NULL DEFAULT ((0));
  • Add new tables
    CREATE TABLE [dbo].[AspNetRoles] (
     [Id]   BIGINT IDENTITY (1, 1) NOT NULL,
     [Name] NVARCHAR (MAX) NOT NULL,
     CONSTRAINT [PK_AspNetRoles] PRIMARY KEY CLUSTERED ([Id] ASC)
    )
    
    CREATE TABLE [dbo].[AspNetUserRoles] (
     [UserId] BIGINT NOT NULL,
     [RoleId] BIGINT NOT NULL,
     CONSTRAINT [PK_AspNetUserRoles] PRIMARY KEY CLUSTERED ([UserId] ASC, [RoleId] ASC),
     CONSTRAINT [FK_AspNetUserRoles_IdentityRole] FOREIGN KEY ([RoleId]) REFERENCES [dbo].[AspNetRoles] ([Id]) ON DELETE CASCADE,
     CONSTRAINT [FK_AspNetUserRoles_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE
    )
    
    CREATE TABLE [dbo].[AspNetUserLogins] (
     [UserId]        BIGINT NOT NULL,
     [LoginProvider] NVARCHAR (128) NOT NULL,
     [ProviderKey]   NVARCHAR (128) NOT NULL,
     CONSTRAINT [PK_AspNetUserLogins] PRIMARY KEY CLUSTERED ([UserId] ASC, [LoginProvider] ASC, [ProviderKey] ASC),
     CONSTRAINT [FK_AspNetUserLogins_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE
    )
    
    CREATE TABLE [dbo].[AspNetUserClaims] (
     [Id]         BIGINT IDENTITY (1, 1) NOT NULL,
     [UserId]     BIGINT NOT NULL,
     [ClaimType]  NVARCHAR (MAX) NULL,
     [ClaimValue] NVARCHAR (MAX) NULL,
     CONSTRAINT [PK_AspNetUserClaims] PRIMARY KEY CLUSTERED ([Id] ASC),
     CONSTRAINT [FK_AspNetUserClaims] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE
    )

    • Update your Entity Framework EDMX (or Code-First POCO) with the USER table changes
    • DO NOT INCLUDE the newly added AspNet* tables since they'll be using a separate context (see Code section below)

    Packages

    • Include the following Nuget Packages
      • Microsoft.AspNet.Identity.Owin
      • Microsoft.Owin.Host.SystemWeb
      • Microsoft.Owin.Security.OAuth (for 3rd Party)
      • Microsoft.Owin.Security.Google (for 3rd Party)
      • Microsoft.AspNet.Identity.EntityFramework (dependency on EF6.1)

    Configuration

    • Add a NEW database connection string to Web.Config
    <add name="TodoConnection" connectionString="Data Source=[TODO];initial catalog=[TODO];integrated security=True;MultipleActiveResultSets=True;" providerName="System.Data.SqlClient" />
    
    • Remove the following Forms Authentication & Membership sections
      • <authentication mode="Forms">...
      • <membership>...
      • <profile>...
      • <roleManager enabled="false">...
    • Add the following within the <system.webServer><modules> section

    <remove name="FormsAuthenticationModule" />
    

    • (optional) Remove DotNetOpenAuth in case you were using it
      • Remove project reference to DotNetOpenAuth
      • Remove the following entries from web.config
        • <section name="dotNetOpenAuth"...
        • <dotNetOpenAuth>...
        • <assemblyIdentity name="DotNetOpenAuth.AspNet"...
        • <assemblyIdentity name="DotNetOpenAuth.Core"...
      • Remove any related classes & views within the solution


    Code

    • Add a new folder i.e. AUTH within your solution
    • Add the following new classes within that folder
    using System.Threading.Tasks;
    using Microsoft.AspNet.Identity;
    
    namespace Todo.Auth
    {
        public class EmailService : IIdentityMessageService
        {
            public Task SendAsync(IdentityMessage message)
            {
                // Create the MailMessage instance
                var mm = new MailMessage();
                mm.From = new MailAddress("todo@todo.todo");
                mm.IsBodyHtml = true;
                mm.To.Add(message.Destination);
                mm.Subject = message.Subject;
                mm.Body = message.Body;
     
                var smtp = new SmtpClient();
                return smtp.SendMailAsync(mm); 
            }
        }
    }
    
    
    using System.ComponentModel.DataAnnotations.Schema;
    using System.Data.Entity;
    using Microsoft.AspNet.Identity.EntityFramework;
    using Todo.Models;
    
    namespace Todo.Auth
    {
        public class AspNetDbContext : IdentityDbContext<AspNetUser, AspNetRole, long, AspNetUserLogin, AspNetUserRole, AspNetUserClaim>
        {
            public AspNetDbContext()
                : base("TodoConnection")
            {
            }
    
            protected override void OnModelCreating(DbModelBuilder modelBuilder)
            {
                base.OnModelCreating(modelBuilder);
    
                modelBuilder.Ignore<User>();
         // TODO: (optional) Map Entities to their tables
                modelBuilder.Entity<AspNetUser>().ToTable("User");
                modelBuilder.Entity<AspNetRole>().ToTable("AspNetRoles");
                modelBuilder.Entity<AspNetUserClaim>().ToTable("AspNetUserClaims");
                modelBuilder.Entity<AspNetUserLogin>().ToTable("AspNetUserLogins");
                modelBuilder.Entity<AspNetUserRole>().ToTable("AspNetUserRoles");
                // TODO: (optional) Set AutoIncrement-Properties
                modelBuilder.Entity<AspNetUser>().Property(r => r.Id).HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity);
                modelBuilder.Entity<AspNetUserClaim>().Property(r => r.Id).HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity);
                modelBuilder.Entity<AspNetRole>().Property(r => r.Id).HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity);
                // TODO: (optional) Override some column mappings that do not match our default
                modelBuilder.Entity<AspNetUser>().Property(r => r.EmailConfirmed).HasColumnName("AspNetEmailConfirmed");
            }
        }    
    }
    
    
    using Microsoft.AspNet.Identity.EntityFramework;
    
    namespace Todo.Auth
    {
        public class AspNetRole  : IdentityRole<long, AspNetUserRole> { }
    }
    
    
    using System;
    using System.Security.Claims;
    using System.Threading.Tasks;
    using Microsoft.AspNet.Identity;
    using Microsoft.AspNet.Identity.EntityFramework;
    
    namespace Todo.Auth
    {
        public class AspNetUser : IdentityUser<long, AspNetUserLogin, AspNetUserRole, AspNetUserClaim>
        {
            // TODO: Add your own custom properties
    
            public async Task<ClaimsIdentity> GenerateUserIdentityAsync(AspNetUserManager userManager)
            {
                var userIdentity = await userManager.CreateIdentityAsync(this, DefaultAuthenticationTypes.ApplicationCookie);
                // Add custom user claims here
                return userIdentity;
            }
        }
    }
    
    
    using Microsoft.AspNet.Identity.EntityFramework;
    
    namespace Todo.Auth
    {
        public class AspNetUserClaim : IdentityUserClaim<long> { }
    }
    
    
    using Microsoft.AspNet.Identity.EntityFramework;
    
    namespace Todo.Auth
    {
        public class AspNetUserLogin : IdentityUserLogin<long> { }
    }
    
    
    using System.Threading.Tasks;
    using Microsoft.AspNet.Identity;
    using Microsoft.AspNet.Identity.EntityFramework;
    using Microsoft.AspNet.Identity.Owin;
    using Microsoft.Owin;
    using Microsoft.Owin.Security.DataProtection;
    
    namespace Todo.Auth
    {
        public class AspNetUserManager : UserManager<AspNetUser, long>
        {
            public AspNetUserManager(IUserStore<AspNetUser, long> store)
                : base(store)
            {
                UserValidator = new UserValidator<AspNetUser, long>(this)
                {
                    // TODO: Add your own custom logic
                };
    
                PasswordValidator =  new PasswordValidator
                {
                    // TODO: Add your own custom logic
                };
    
                EmailService = new EmailService();
                
                PasswordHasher = new TodoPasswordHasher();
            }
    
            public override Task<AspNetUser> FindAsync(UserLoginInfo login)
            {
                return base.FindAsync(login);
            }
        }
    }
    
    
    using Microsoft.AspNet.Identity.EntityFramework;
    
    namespace Todo.Auth
    {
        public class AspNetUserRole : IdentityUserRole<long> { }
    }
    
    
    using System;
    using System.Security.Cryptography;
    using System.Text;
    using Microsoft.AspNet.Identity;
    
    namespace Todo.Auth
    {
        public class TodoPasswordHasher : PasswordHasher
        {
            public override PasswordVerificationResult VerifyHashedPassword(string hashedPassword, string providedPassword)
            {
                return (hashedPassword == HashPassword(providedPassword))
                    ? PasswordVerificationResult.Success
                    : PasswordVerificationResult.Failed;
            }
    
            public override string HashPassword(string password)
            {
                // TODO: Add your own custom logic
            }
        }
    }
    
    
    using System.Threading.Tasks;
    using Microsoft.AspNet.Identity;
    
    namespace Todo.Auth
    {
        public class SmsService : IIdentityMessageService
        {
            public Task SendAsync(IdentityMessage message)
            {
                // Plug in your sms service here to send a text message.
                return Task.FromResult(0);
            }
        }
    }
    
    
    • Setup your Startup classes
    
    using Microsoft.Owin;
    using Owin;
    
    [assembly: OwinStartupAttribute(typeof(Todo.Startup))]
    namespace Todo
    {
        public partial class Startup
        {
            public void Configuration(IAppBuilder app)
            {
                ConfigureAuth(app);
            }
        }
    }
    
    
    using System;
    using Microsoft.AspNet.Identity;
    using Microsoft.AspNet.Identity.Owin;
    using Microsoft.Owin;
    using Microsoft.Owin.Security.Cookies;
    using Microsoft.Owin.Security.DataProtection;
    using Owin;
    using Todo.Auth;
    
    namespace Todo
    {
        public partial class Startup
        {
            // For more information on configuring authentication, please visit http://go.microsoft.com/fwlink/?LinkId=301864
            public void ConfigureAuth(IAppBuilder app)
            {
                // Enable the application to use a cookie to store information for the signed in user
                // and to use a cookie to temporarily store information about a user logging in with a third party login provider
                // Configure the sign in cookie
                app.UseCookieAuthentication(new CookieAuthenticationOptions
                {
                    AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
                    LoginPath = new PathString("/Account/Login"),
                    Provider = new CookieAuthenticationProvider
                    {
                        OnValidateIdentity = SecurityStampValidator.OnValidateIdentity(
                            TimeSpan.FromMinutes(30),
                            (manager, user) => user.GenerateUserIdentityAsync(manager),
                            identity => long.Parse(identity.GetUserId()))
                    }
                });
    
                app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
    
    ...
            }
        }
    }
    
    
    • (optional) Setup Unity IoC in the UnityConfig class
    // register asp.net identity user store
    container.RegisterType<IUserStore<AspNetUser, long>, UserStore<AspNetUser, AspNetRole, long, AspNetUserLogin, AspNetUserRole, AspNetUserClaim>>(new PerRequestLifetimeManager(), new InjectionConstructor(new AspNetDbContext()));
    // register asp.net identity user token provider 
    container.RegisterType<IUserTokenProvider<AspNetUser, long>, EmailTokenProvider<AspNetUser, long>>(new PerRequestLifetimeManager());
    
    • Create a separate clean default ASP.NET MVC 5 solution as we're going to copy over some files from it
    • Manage Account ViewModels
      • Remove the existing Models/AccountModels.cs class
      • Add a new default AccountViewModels.cs class (from ASP.NET MVC 5 default solution)
    • Manage Account Controller
      • Add a new default AccountController (from ASP.NET MVC 5 default solution)
      • Inject the UserManager via the AccountController
      
      public class AccountController : Controller 
      {
         private AspNetUserManager UserManager;
      
         public AccountController()
         {
         }
      
         public AccountController(AspNetUserManager userManager, IUserTokenProvider<AspNetUser, long> userTokenProvider)
         {
            UserManager = userManager;
            UserManager.UserTokenProvider = userTokenProvider;
         } 
      
      
      • Manage Account Views
        • Remove ALL existing views under /Views/Account
        • Add all new views (from ASP.NET MVC 5 default solution)
      • Note: Depending on your solution you may have to do some refactoring to the AccountViewModel /  AccountController & Account Views 

      Tips

      • Do not include the EMAIL property in the AspNetUser class since this will cause the Email Service to stop working with a Destination NULL error
      • Include modelBuilder.Ignore<User>() in the AspNetDbContext class otherwise User and AspNetUser will clash
      • If you're not using the EmailConfirmation field then explicitly set it to TRUE in the Account Controller - Register action when creating the new AspNetUser since this will cause the Forgot Password functionality to prevent users from logging in
      [original publish date: 02/19/15]

      Comments

      Popular posts from this blog

      ASP.NET Identity Remember Me

      IIS Express Client Certificates

      ASP.NET MVC - How to enable/disable CaC/Client Certificate authentication per area or route.