ASP.NET Core 3.1 - 2FA User Tokens
This article will describe the implementation of a TwoFactorEnabled property for the user and a related AppUserToken entity/table. I will assume you have downloaded the ASP.NET Core 3.1 - Users Without Identity Project or created a new ASP.NET Core 3.1 Razor Pages project. See Tutorial: Get started with Razor Pages in ASP.NET Core. You should review the earlier articles of the Users Without Identity Project series.
Users Without Identity Project and Article Series
Creation Series
- ASP.NET Core 3.1 - Users Without Identity
 - ASP.NET Core 3.1 - User Entity
 - ASP.NET Core 3.1 - Password Hasher
 - ASP.NET Core 3.1 - User Management
 - ASP.NET Core 3.1 - Admin Role
 - ASP.NET Core 3.1 - Cookie Validator
 - ASP.NET Core 3.1 - Concurrency Conflicts
 - ASP.NET Core 3.1 - Must Change Password
 - ASP.NET Core 3.1 - User Database Service
 - ASP.NET Core 3.1 - Rename Related Entities
 
2FA Series
- ASP.NET Core 3.1 - 2FA Without Identity
 - ASP.NET Core 3.1 - 2FA User Tokens
 - ASP.NET Core 3.1 - 2FA Cookie Schemes
 - ASP.NET Core 3.1 - 2FA Authenticating
 - ASP.NET Core 3.1 - 2FA Sign In Service
 - ASP.NET Core 3.1 - 2FA QR Code Generator
 - ASP.NET Core 3.1 - Admin 2FA Requirement
 - ASP.NET Core 3.1 - 2FA Admin Override
 
Enhanced User Series
- ASP.NET Core 3.1 - Enhanced User Without Identity
 - ASP.NET Core 3.1 - 2FA Recovery Codes
 - ASP.NET Core 3.1 - Login Lockout
 - ASP.NET Core 3.1 - Created And Last Login Date
 - ASP.NET Core 3.1 - Security Stamp
 - ASP.NET Core 3.1 - Token Service
 - ASP.NET Core 3.1 - Confirmed Email Address
 - ASP.NET Core 3.1 - Password Recovery
 
The Users Without Identity Project (UWIP) implements AppUser, a minimal entity which is created by administrators who invite the user to login. To implement 2FA, we need a TwoFactorEnabled property for the user and a related AppUserToken entity.
Add the TwoFactorEnabled property and the virtual AppUserTokens navigational property to AppUser.
Edit Entities > AppUser.cs:
public class AppUser
{
    [Key]
    public int Id { get; set; }
    [Required]
    [Display(Name = "Login Name")]
    [StringLength(32, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
    public string LoginName { get; set; }
    [Required]
    [StringLength(32)]
    public string LoginNameUppercase { get; set; }
    [Required]
    [MaxLength(84, ErrorMessage = "The {0} is max {1} characters long.")]
    public string PasswordHash { get; set; }
    [Display(Name = "MCP")]
    public bool MustChangePassword { get; set; }
    [Display(Name = "2FA")]
    public bool TwoFactorEnabled { get; set; }
    [Display(Name = "Admin")]
    public bool IsAdmin { get; set; }
    [Timestamp]
    public byte[] RowVersion { get; set; }
    public virtual ICollection<AppUserToken> AppUserTokens { get; set; }
}
    Add the AppUserToken entity with the virtual AppUser navigational property.
Entities > AppUserToken.cs:
public class AppUserToken
{
    [Required]
    public int AppUserId { get; set; }
    [Required]
    [StringLength(128)]
    public string Name { get; set; }
    public string Value { get; set; }
    public virtual AppUser AppUser { get; set; }
}
    Add the AppUserTokens property to ApplicationDbContext and the composite primary key to the OnModelCreating method.
Edit Data > ApplicationDbContext.cs:
public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }
    public DbSet<AppUser> AppUsers { get; set; }
    public DbSet<AppUserToken> AppUserTokens { get; set; }
    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
        // Set a unique constraint for LoginNameUppercase
        builder.Entity<AppUser>()
            .HasIndex(u => u.LoginNameUppercase)
            .IsUnique();
        // Composite primary key
        builder.Entity<AppUserToken>()
            .HasKey(t => new { t.AppUserId, t.Name })
            .IsClustered(false);
    }
}
    Use the Package Manager Console to add an InitialAppUserTokens migration and update the database. The navigational properties will create a ForeignKey with a cascade delete rule.
public partial class InitialAppUserTokens : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<bool>(
            name: "TwoFactorEnabled",
            table: "AppUsers",
            nullable: false,
            defaultValue: false);
        migrationBuilder.CreateTable(
            name: "AppUserTokens",
            columns: table => new
            {
                AppUserId = table.Column<int>(nullable: false),
                Name = table.Column<string>(maxLength: 128, nullable: false),
                Value = table.Column<string>(nullable: true)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_AppUserTokens", x => new { x.AppUserId, x.Name })
                    .Annotation("SqlServer:Clustered", false);
                table.ForeignKey(
                    name: "FK_AppUserTokens_AppUsers_AppUserId",
                    column: x => x.AppUserId,
                    principalTable: "AppUsers",
                    principalColumn: "Id",
                    onDelete: ReferentialAction.Cascade);
            });
    }
    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropTable(
            name: "AppUserTokens");
        migrationBuilder.DropColumn(
            name: "TwoFactorEnabled",
            table: "AppUsers");
    }
}
    Update 02/23/2021
I added the Enhanced User Series' article links.
Comments(0)