Миграция ASP.NET MVC Identity на ASP.NET Core Identity
При попытке мигрировать один мой проект на .NET core я столкнулся с тем, что достаточно мало толковых статей про то, как перенести пользователей и роли на .NET Core 3.0.
Из того полезного, что я нашел было только несколько обзорных статей и советов:
- https://stackoverflow.com/questions/53878000/how-to-migrate-identity-users-from-a-mvc5-app-to-a-asp-net-core-2-2-app
- https://docs.microsoft.com/en-us/aspnet/core/migration/proper-to-2x/membership-to-core-identity?view=aspnetcore-2.2
В этой статье я разберу как за 10 шагов мигрировать свое ASP.NET MVC приложение на ASP.NET Core.
Подготовка тестового проекта
В качестве проекта который я буду мигрировать, я возьму свой старый MVC проект. Давайте для того, чтобы немного усложнить задачу, добавим к пользователю еще одно entity – hobby и сделаем связь many-to-many.
public class Hobby : BaseEntity
{
public string Name { get; set; }
public ICollection<ApplicationUser> Users{ get; set; }
}
Entity User'a я модифицирую следующим образом:
public class ApplicationUser : IdentityUser
{
public ICollection<Hobby> Hobbies { get; set; }
public async Task<ClaimsIdentity> GenerateUserIdentityAsync(UserManager<ApplicationUser> manager)
{
// Note the authenticationType must match the one defined in CookieAuthenticationOptions.AuthenticationType
var userIdentity = await manager.CreateIdentityAsync(this, DefaultAuthenticationTypes.ApplicationCookie);
// Add custom user claims here
return userIdentity;
}
}
Для того, чтобы в базе были хоть какие-то данные по юзеру и его хобби, добавим Seed данных:
protected override void Seed(ApplicationDbContext context)
{
var userManager = new ApplicationUserManager(new UserStore<ApplicationUser>(context));
var roleManager = new ApplicationRoleManager(new RoleStore<IdentityRole>(context));
const string adminRoleName = "Administrator";
var roles = new List<IdentityRole>
{
new IdentityRole
{
Name = adminRoleName
},
new IdentityRole
{
Name = "User"
}
};
foreach (var identityRole in roles)
{
var existingRole = roleManager.FindByName(identityRole.Name);
if (existingRole == null)
{
context.Roles.Add(identityRole);
}
}
var hobbies = new List<Hobby>
{
new Hobby
{
Name = "Танцы"
},
new Hobby
{
Name = "Рисование"
}
};
context.Hobbies.AddRange(hobbies);
var user = new ApplicationUser
{
UserName = "test@test.com",
Email = "test@test.com",
Hobbies = hobbies
};
userManager.Create(user, "123456");
base.Seed(context);
}
Так как в моем проекте уже была миграция, то я сначала применю ее для новой базы выполнив команду update-database
в package manager console.
Теперь добавим новую миграцию и выполняем ее:
add-migration AddHobbies
update-database
Открыв вашу базу данных в SQL Management studio вы должны увидеть следующее:
У вас должна появиться таблица с хобби и таблица для связки many to many хобби и юзера.
Давайте попробуем теперь залогиниться с использованием нашего логина и пароля:
Как видим, мы успешно залогинились:
Миграция ASP.NET MVC приложения на ASP.NET Core
Шаг 1: Создание Web проекта
Создаем новый solution с ASP.NET Core веб проектом
и выбираем имя проекта и solution'а
В качестве шаблона я выбираю Web Application (MVC)
Готово, веб проект создан.
Шаг 2: Создание Data проекта
Приступим к созданию Data проекта, где будет хранится наш контекст и Entities.
Создаем class library проект:
Называем наш проект "data"
Шаг 3: Генерация DB контекста
Теперь нам нужно используя Package Manger Console сгенерировать контекст который нами будет использоваться в качестве "черновика".
Для этого мы воспользуемся командой c PMC. Но перед этим нам нужно установить необходимые nuget пакеты:
Install-Package Microsoft.EntityFrameworkCore.Tools
Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.AspNetCore.Identity.EntityFrameworkCore
После установки этих пакетов, выполним генерацию Db контекста и наших моделей с помощью команды:
Scaffold-DbContext "Data Source=localhost;Initial Catalog=migration-test;Integrated Security=True;MultipleActiveResultSets=True" Microsoft.EntityFrameworkCore.SqlServer -OutputDir MigrationEntities
где Data Source
это connectionString к нашей локальной базе, которая использовалась в MVC проекте.
В результате у нас должен был сгенерироваться контекст и entities.
Шаг 4: Nuget пакеты для веб проекта
Добавим в конфигурацию connectionstrings для нашей базы
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Initial Catalog=migration-test;Integrated Security=True;MultipleActiveResultSets=True"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}
И установим недостающие nuget пакеты:
Install-Package Microsoft.AspNetCore
Install-Package Microsoft.AspNetCore.Identity
Install-Package Microsoft.EntityFrameworkCore
Install-Package Microsoft.AspNetCore.Identity.EntityFrameworkCore
Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.EntityFrameworkCore.Design
Шаг 5: Перенос Entities и DB контекста в дата проекте
Начинаем переносить Entity в новый проект. Просто скопируем папку Entities с нашего старого проекта, в наш новый .NET Core дата проект
Удаляем GenerateUserIdentityAsync
метод с ApplicationUser класса и пока закомментируем Hobbies Entity
public class ApplicationUser : IdentityUser
{
//public ICollection<Hobby> Hobbies { get; set; }
}
Переносим наш Db контекст с старого проекта и модифицируем его, чтобы он выглядел таким образом:
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityRole, string>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<ApplicationUser>(entity =>
{
entity.Property(e => e.UserName)
.IsRequired()
.HasMaxLength(256);
});
}
}
Для того, чтобы старые пользователи корректно логинились при новой схеме. Нужно добавить обработчик старого хэша и мигратор на новый тип хэша.
Создадим класс:
public class OldMvcPasswordHasher : PasswordHasher<ApplicationUser>
{
public override PasswordVerificationResult VerifyHashedPassword(ApplicationUser user, string hashedPassword, string providedPassword)
{
// if it's the new algorithm version, delegate the call to parent class
if (user.HashVersion == PasswordHashVersion.Core)
return base.VerifyHashedPassword(user, hashedPassword, providedPassword);
byte[] buffer4;
if (hashedPassword == null)
{
return PasswordVerificationResult.Failed;
}
if (providedPassword == null)
{
throw new ArgumentNullException("providedPassword");
}
byte[] src = Convert.FromBase64String(hashedPassword);
if ((src.Length != 0x31) || (src[0] != 0))
{
return PasswordVerificationResult.Failed;
}
byte[] dst = new byte[0x10];
Buffer.BlockCopy(src, 1, dst, 0, 0x10);
byte[] buffer3 = new byte[0x20];
Buffer.BlockCopy(src, 0x11, buffer3, 0, 0x20);
using (Rfc2898DeriveBytes bytes = new Rfc2898DeriveBytes(providedPassword, dst, 0x3e8))
{
buffer4 = bytes.GetBytes(0x20);
}
if (AreHashesEqual(buffer3, buffer4))
{
user.HashVersion = PasswordHashVersion.Core;
return PasswordVerificationResult.SuccessRehashNeeded;
}
return PasswordVerificationResult.Failed;
}
private bool AreHashesEqual(byte[] firstHash, byte[] secondHash)
{
int minHashLength = firstHash.Length <= secondHash.Length ? firstHash.Length : secondHash.Length;
var xor = firstHash.Length ^ secondHash.Length;
for (int i = 0; i < minHashLength; i++)
xor |= firstHash[i] ^ secondHash[i];
return 0 == xor;
}
}
И Enum
public enum PasswordHashVersion
{
OldMvc,
Core
}
Теперь добавим в ApplicationUser новое проперти:
public class ApplicationUser : IdentityUser
{
public PasswordHashVersion HashVersion { get; set; }
//public ICollection<Hobby> Hobbies { get; set; }
}
Шаг 6: Конфигурация web проекта
Конфигурируем наш Startup.cs:
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection")));
services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequiredLength = 6;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
options.Password.RequireLowercase = false;
options.User.AllowedUserNameCharacters =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
options.User.RequireUniqueEmail = true;
}).AddEntityFrameworkStores<ApplicationDbContext>();
services.AddScoped<SignInManager<ApplicationUser>, SignInManager<ApplicationUser>>();
services.Replace(new ServiceDescriptor(
serviceType: typeof(IPasswordHasher<ApplicationUser>),
implementationType: typeof(OldMvcPasswordHasher),
ServiceLifetime.Scoped));
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie();
services.AddMvc(options => options.EnableEndpointRouting = false).SetCompatibilityVersion(CompatibilityVersion.Latest);
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
}
Program.cs
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IWebHostBuilder CreateHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseContentRoot(Directory.GetCurrentDirectory())
.UseIISIntegration()
.UseStartup<Startup>();
}
Шаг 7: Миграция базы данных
Следующим шагом у нас будет миграция нашей базы данных, для этого нам нужно выполнить несколько sql скриптов:
Добавляем нужные таблицы:
/*
Generate new tables AspNetRoleClaims,AspNetUserTokens
*/
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[AspNetRoleClaims](
[Id] [int] IDENTITY(1,1) NOT NULL,
[ClaimType] [nvarchar](max) NULL,
[ClaimValue] [nvarchar](max) NULL,
[RoleId] [nvarchar](450) NOT NULL,
CONSTRAINT [PK_AspNetRoleClaims] PRIMARY KEY CLUSTERED
(
[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO
/****** Object: Table [dbo].[AspNetUserTokens] Script Date: 17/5/2018 7:25:29 AM ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[AspNetUserTokens](
[UserId] [nvarchar](450) NOT NULL,
[LoginProvider] [nvarchar](450) NOT NULL,
[Name] [nvarchar](450) NOT NULL,
[Value] [nvarchar](max) NULL,
CONSTRAINT [PK_AspNetUserTokens] PRIMARY KEY CLUSTERED
(
[UserId] ASC,
[LoginProvider] ASC,
[Name] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO
Добавляем таблицу миграций .NET Core и первую миграцию:
GO
/* Object: Table [dbo].[__EFMigrationsHistory] */
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[__EFMigrationsHistory](
[MigrationId] [nvarchar](150) NOT NULL,
[ProductVersion] [nvarchar](32) NOT NULL,
CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY CLUSTERED
(
[MigrationId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
INSERT [dbo].[__EFMigrationsHistory] ([MigrationId], [ProductVersion]) VALUES (N'00000000000000_CreateIdentitySchema', N'2.0.2-rtm-10011')
GO
Меняем структуру таблиц пользователей, и ролей:
/*
AspNetUsers table changes
*/
/*
AspNetUsers table changes
*/
GO
DROP INDEX [UserNameIndex] ON [AspNetUsers];
GO
EXEC sp_rename N'AspNetUsers.LockoutEndDateUtc', N'LockoutEnd', N'COLUMN';
GO
ALTER TABLE [AspNetUsers] ADD [ConcurrencyStamp] nvarchar(max) NULL;
GO
ALTER TABLE [AspNetUsers] ADD [NormalizedEmail] nvarchar(256) NULL;
GO
ALTER TABLE [AspNetUsers] ADD [NormalizedUserName] nvarchar(256) NULL;
GO
drop index [EmailIndex] on [AspNetUsers]
CREATE INDEX [EmailIndex] ON [AspNetUsers] ([NormalizedEmail]);
GO
drop index [UserNameIndex] on [AspNetUsers]
CREATE UNIQUE INDEX [UserNameIndex] ON [AspNetUsers] ([NormalizedUserName]) WHERE [NormalizedUserName] IS NOT NULL;
GO
/*
AspNetRoles table changes
*/
GO
DROP INDEX [RoleNameIndex] ON [AspNetRoles];
GO
ALTER TABLE [AspNetRoles] ADD [ConcurrencyStamp] nvarchar(max) NULL;
GO
ALTER TABLE [AspNetRoles] ADD [NormalizedName] nvarchar(256) NULL;
GO
drop index [RoleNameIndex] on [AspNetRoles]
CREATE UNIQUE INDEX [RoleNameIndex] ON [AspNetRoles] ([NormalizedName]) WHERE [NormalizedName] IS NOT NULL;
GO
И последним скриптом обновляем пользователей:
UPDATE AspNetUsers SET NormalizedEmail = UPPER(Email), NormalizedUserName = UPPER(username)
ALTER TABLE [AspNetUsers]
ADD HashVersion int NOT NULL DEFAULT(0)
Шаг 8: Вход пользователей
Добавим контроллер для входа пользователей:
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using data.Entities;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace web.Controllers
{
public class AccountController : Microsoft.AspNetCore.Mvc.Controller
{
private readonly SignInManager<ApplicationUser> _signInManager;
public AccountController(SignInManager<ApplicationUser> signInManager)
{
_signInManager = signInManager;
}
[HttpGet]
public IActionResult Login(string returnUrl = null)
{
return View(new LoginModel { ReturnUrl = returnUrl });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginModel model)
{
if (ModelState.IsValid)
{
var result =
await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, false);
if (result.Succeeded)
{
if (model.ReturnUrl != null)
{
return LocalRedirect(model.ReturnUrl);
}
return RedirectToAction("Index", "Home");
}
ModelState.AddModelError("", "Неправильный логин и (или) пароль");
}
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout()
{
await _signInManager.SignOutAsync();
return RedirectToAction("Index", "Home");
}
}
public class LoginModel
{
[Required]
[Display(Name = "Email")]
public string Email { get; set; }
[Required]
[DataType(DataType.Password)]
[Display(Name = "Пароль")]
public string Password { get; set; }
[Display(Name = "Запомнить меня?")]
public bool RememberMe { get; set; }
public string ReturnUrl { get; set; }
}
}
Создадим View для логина:
@model web.Controllers.LoginModel
<partial name="_ValidationScriptsPartial"/>
<h2>Вход в приложение</h2>
<form method="post" asp-controller="Account" asp-action="Login"
asp-route-returnUrl="@Model.ReturnUrl">
<div asp-validation-summary="ModelOnly"></div>
<div>
<label asp-for="Email"></label><br />
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
</div>
<div>
<label asp-for="Password"></label><br />
<input asp-for="Password" />
<span asp-validation-for="Password"></span>
</div>
<div>
<label asp-for="RememberMe"></label><br />
<input asp-for="RememberMe" />
</div>
<div>
<input type="submit" value="Войти" />
</div>
</form>
Модифицируем Layout, добавим в него индикатор того, что юзер успешно залогинился в систему:
@if(User.Identity.IsAuthenticated)
{
<p>@User.Identity.Name</p>
<form method="post" asp-controller="Account" asp-action="Logout">
<input type="submit" value="Выход" />
</form>
}
else
{
<a asp-controller="Account" asp-action="Login">Вход</a>
<a asp-controller="Account" asp-action="Register">Регистрация</a>
}
Запускаем проект и проверяем работает ли вход.
Вводим данные которые мы использовали для входа пользователя в Old MVC приложении
Отлично, как видим все заработало.
Шаг 9: Миграция Many-to-Many
Для того, чтобы мигрировать many-to-many таблицы, необходимо сделать несколько манипуляций, EntityFrameworkCore
уже не работает по той же схеме как и EF
с .NET framework. Теперь для этого нужно создавать отдельную Entity. Обратимся к папке MigrationEntities, которую мы сгенерировали на шаге 3. Возмем оттуда файл ApplicationUserHobbies.cs
и модифицируем его следующим образом:
public class ApplicationUserHobbies
{
public string ApplicationUserId { get; set; }
public Guid HobbyId { get; set; }
public virtual ApplicationUser ApplicationUser { get; set; }
public virtual Hobby Hobby { get; set; }
}
А entity хобби так:
public class Hobby : BaseEntity
{
public string Name { get; set; }
public virtual ICollection<ApplicationUserHobbies> ApplicationUserHobbies { get; set; }
}
ApplicationUser:
public class ApplicationUser : IdentityUser
{
public PasswordHashVersion HashVersion { get; set; }
public ICollection<ApplicationUserHobbies> ApplicationUserHobbies { get; set; }
}
Теперь добавим таблицы и модифицируем OnModelCreating с помощью контекста. который нам автоматически сгенерировал EF на шаге 3.
Наш ApplicationDbContext должен выглядеть следующим образом:
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityRole, string>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public virtual DbSet<Hobby> Hobbies { get; set; }
public virtual DbSet<ApplicationUserHobbies> ApplicationUserHobbies { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<ApplicationUser>(entity =>
{
entity.Property(e => e.UserName)
.IsRequired()
.HasMaxLength(256);
});
modelBuilder.Entity<ApplicationUserHobbies>(entity =>
{
entity.HasKey(e => new { e.ApplicationUserId, e.HobbyId })
.HasName("PK_dbo.ApplicationUserHobbies");
entity.HasIndex(e => e.ApplicationUserId)
.HasName("IX_ApplicationUser_Id");
entity.HasIndex(e => e.HobbyId)
.HasName("IX_Hobby_Id");
entity.Property(e => e.ApplicationUserId)
.HasColumnName("ApplicationUser_Id")
.HasMaxLength(128);
entity.Property(e => e.HobbyId).HasColumnName("Hobby_Id");
entity.HasOne(d => d.ApplicationUser)
.WithMany(p => p.ApplicationUserHobbies)
.HasForeignKey(d => d.ApplicationUserId)
.HasConstraintName("FK_dbo.ApplicationUserHobbies_dbo.AspNetUsers_ApplicationUser_Id");
entity.HasOne(d => d.Hobby)
.WithMany(p => p.ApplicationUserHobbies)
.HasForeignKey(d => d.HobbyId)
.HasConstraintName("FK_dbo.ApplicationUserHobbies_dbo.Hobbies_Hobby_Id");
});
modelBuilder.Entity<Hobbies>(entity =>
{
entity.Property(e => e.Id).ValueGeneratedNever();
entity.Property(e => e.AddedDate).HasColumnType("datetime");
});
}
}
Шаг 10: Работа с Many-to-Many
Для того, чтобы проверить работает ли контекст после наших манипуляций, давайте создадим контроллер, в котором выведем хобби пользователя:
[Authorize]
public class HobbiesController : Controller
{
private readonly ApplicationDbContext _context;
public HobbiesController(ApplicationDbContext context)
{
_context = context;
}
public IActionResult Index()
{
var hobbies = _context.ApplicationUserHobbies
.Include(userHobbies => userHobbies.Hobby)
.Include(userHobbies => userHobbies.ApplicationUser)
.Where(userHobbies => userHobbies.ApplicationUser.UserName == User.Identity.Name)
.Select(userHobbies => new HobbyDto
{
Id = userHobbies.Hobby.Id,
Name = userHobbies.Hobby.Name
}).ToList();
return View(hobbies);
}
}
public class HobbyDto
{
public Guid Id { get; set; }
public string Name { get; set; }
}
View
@model System.Collections.Generic.List<web.Controllers.HobbyDto>
<h1>Hobbies:</h1>
@foreach (var item in Model)
{
<p>@item.Name</p>
}
Запускаем проект и переходим на страницу /hobbies
Как видим, все работает, путем нехитрых манипуляций мы мигрировали пользователей и роли ASP.NET MVC на ASP.NET Core 3.0.
Исходники проекта можно найти тут.