4

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.

MattC
  • 671
  • 1
  • 6
  • 15
  • Try this https://stackoverflow.com/questions/34343599/how-to-seed-users-and-roles-with-code-first-migration-using-identity-asp-net-cor – Nouman Janjua Nov 19 '19 at 17:57
  • Thanks, but this suggests how to seed data at app startup, which - as I made clear in my question - I am able to do. The issue is seeding data that includes a password as part of EF migrations. – MattC Nov 20 '19 at 08:59

3 Answers3

2

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

I use both ways and get NotAllowed since EmailConfirmed property is false when I seed data.

While asp.net core 3.0 Identity is registerd by default like below which set RequireConfirmedAccount = true to require email confirmation.

services.AddDefaultIdentity<ApplicationUser>(options => options.SignIn.RequireConfirmedAccount = true)
            .AddEntityFrameworkStores<ApplicationDbContext>();

When I remove above options or add it in data seeding and then login successfully:

var appUser = new ApplicationUser
        {
            UserName = "admin",
            Email = "admin@contoso.com",
            NormalizedUserName = "ADMIN",
            NormalizedEmail = "ADMIN@CONTOSO.COM",
            EmailConfirmed = true,
            SecurityStamp = Guid.NewGuid().ToString()
        };
Ryan
  • 19,118
  • 10
  • 37
  • 53
  • Thanks for the comment. Unfortunately, I've already set `RequireConfirmedAccount` to be `false` when setting up the default identity. ```services.AddDefaultIdentity(options => options.SignIn.RequireConfirmedAccount = false) .AddRoles() .AddEntityFrameworkStores() .AddDefaultTokenProviders();``` – MattC Nov 20 '19 at 08:55
  • @codeandchips I test in a new project and seed user data only(without roles) and it works.Have you tried that? – Ryan Nov 20 '19 at 08:59
  • Thanks for the quick reply! I've just tried it with `EmailConfirmed` explicitly set to `true`, and also removing the roles and just seeding the user account, but still get the same problem, unfortunately. – MattC Nov 20 '19 at 09:08
0

I just had this issue and noticed I had not set my NormalizedUsername / NormalizedEmail fields. I set these to be identical to the Username / Email values and it suddenly worked. Maybe you need to make your normalized fields exactly the same as the original fields.

Mark Seymour
  • 111
  • 4
  • 16