How to seed ASP.NET Core Identity users as part of EF Migrations

asp.net-core asp.net-identity c# entity-framework-core entity-framework-migrations

Question

Summary:

I've been trying to seed a user as part of an ASP.NET Core 3.0 project that uses Identity 3.0 with local user accounts, but am having an issue with not being able to log in when seeding via an EF migration; it works if I do it on app startup though.

The Working Approach (On App Startup):

If I create a static initialiser class and call it in the Configure method of my Startup.cs then everything works fine and I can log in with no problem afterwards.

ApplicationDataInitialiser.cs

public static class ApplicationDataInitialiser
    {
        public static void SeedData(UserManager<ApplicationUser> userManager, RoleManager<ApplicationRole> roleManager)
        {
            SeedRoles(roleManager);
            SeedUsers(userManager);
        }

        public static void SeedUsers(UserManager<ApplicationUser> userManager)
        {
            if (userManager.FindByNameAsync("admin").Result == null)
            {
                var user = new ApplicationUser
                {
                    UserName = "admin",
                    Email = "admin@contoso.com",
                    NormalizedUserName = "ADMIN",
                    NormalizedEmail = "ADMIN@CONTOSO.COM"
                };

                var password = "PasswordWouldGoHere";

                var result = userManager.CreateAsync(user, password).Result;

                if (result.Succeeded)
                {
                    userManager.AddToRoleAsync(user, "Administrator").Wait();
                }
            }
        }

        public static void SeedRoles(RoleManager<ApplicationRole> roleManager)
        {
            if (!roleManager.RoleExistsAsync("Administrator").Result)
            {
                var role = new ApplicationRole
                {
                    Name = "Administrator"
                };
                roleManager.CreateAsync(role).Wait();
            }
        }
    }

Startup.cs

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllersWithViews();
        services.AddRazorPages();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env, UserManager<ApplicationUser> userManager, RoleManager<ApplicationRole> roleManager)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
            app.UseDatabaseErrorPage();
        }
        else
        {
            app.UseExceptionHandler("/Home/Error");
            app.UseHsts();
        }
        app.UseHttpsRedirection();
        app.UseStaticFiles();

        app.UseRouting();

        app.UseAuthentication();
        app.UseAuthorization();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=Index}/{id?}");
            endpoints.MapRazorPages();
        });

        ApplicationDataInitialiser.SeedData(userManager, roleManager);
    }
}

The problem with this is that running this at startup means that the password must be available to the application at that time, which either means committing it to source control (not a good idea, obviously) or possibly passing it in as an environment variable (which would mean it's available on the host machine, and therefore that's also a potential issue).

The Non-Working Approach (During EF Migration):

So as a result I've been looking at seeding the user and roles in the OnModelCreating method of the context, and generating an EF migration script when I need it rather than committing the migration to source control.

MyContext.cs - OnModelCreating Method

protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        builder.Entity<ApplicationUser>(b =>
        {
            b.HasMany(e => e.UserRoles)
                .WithOne(e => e.User)
                .HasForeignKey(ur => ur.UserId);
        });

        builder.Entity<ApplicationRole>(b =>
        {
            b.HasMany(e => e.UserRoles)
                .WithOne(e => e.Role)
                .HasForeignKey(ur => ur.RoleId)
                .OnDelete(DeleteBehavior.Restrict);
        });

        var adminRole = new ApplicationRole { Name = "Administrator", NormalizedName = "ADMINISTRATOR" };

        var appUser = new ApplicationUser
        {
            UserName = "admin",
            Email = "admin@contoso.com",
            NormalizedUserName = "ADMIN",
            NormalizedEmail = "ADMIN@CONTOSO.COM",
            SecurityStamp = Guid.NewGuid().ToString()
        };

        var hasher = new PasswordHasher<ApplicationUser>();
        appUser.PasswordHash = hasher.HashPassword(appUser, "PasswordWouldBeHere");

        builder.Entity<ApplicationRole>().HasData(
            adminRole
        );
        builder.Entity<ApplicationUser>().HasData(
            appUser
        );
        builder.Entity<ApplicationUserRole>().HasData(
            new ApplicationUserRole { RoleId = adminRole.Id, UserId = appUser.Id }
        );
    }

The issue with this is that although this appears to succeed, and the password hash shows up in the database with the same length as per the other method, when I try to log in with the account when it has been generated this way, I constantly get a failure indicating that the password is incorrect.

I've overridden the Identity file Login.cshtml.cs and have modified the OnPostAsync method to use the UserName property to check the credentials, and it's the PasswordSignInAsync method which fails each time. It's not due to the account being locked or any of the other possibilities, as they come back as false in the result object. This is a fresh DB with a fresh app, and so it should be using the same compatibility version of the password hasher back in my context file.

Login.cshtml.cs - OnPostAsync method

public async Task<IActionResult> OnPostAsync(string returnUrl = null)
    {
        returnUrl = returnUrl ?? Url.Content("~/");

        if (ModelState.IsValid)
        {
            // This doesn't count login failures towards account lockout
            // To enable password failures to trigger account lockout, set lockoutOnFailure: true
            var result = await _signInManager.PasswordSignInAsync(Input.UserName, Input.Password, Input.RememberMe, lockoutOnFailure: false);
            if (result.Succeeded)
            {
                _logger.LogInformation("User logged in.");
                return LocalRedirect(returnUrl);
            }
            if (result.RequiresTwoFactor)
            {
                return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
            }
            if (result.IsLockedOut)
            {
                _logger.LogWarning("User account locked out.");
                return RedirectToPage("./Lockout");
            }
            else
            {
                ModelState.AddModelError(string.Empty, "Invalid login attempt.");
                return Page();
            }
        }

        // If we got this far, something failed, redisplay form
        return Page();
    }

So the main thing for me is to try to see why the password is not accepted when the user is seeded from the OnModelCreating method - it might be an issue with the PasswordHasher, but I haven't been able to see any examples where what I've done appears to be wrong.

1
0
11/19/2019 5:41:43 PM

Popular Answer

I think the problem here is that your front-end is expecting username to be an email address rather than the simple "admin" you have. Try changing your app user to:

var appUser = new ApplicationUser
    {
        UserName = "admin@contoso.com",
        Email = "admin@contoso.com",
        NormalizedUserName = "ADMIN@CONTOSO.COM",
        NormalizedEmail = "ADMIN@CONTOSO.COM",
        SecurityStamp = Guid.NewGuid().ToString()
    };

Or not enforcing your login name as an email address

0
4/5/2020 12:45:48 PM


Related Questions





Related

Licensed under: CC-BY-SA with attribution
Not affiliated with Stack Overflow
Licensed under: CC-BY-SA with attribution
Not affiliated with Stack Overflow