diff --git a/samples/IdentitySample.Mvc/Controllers/AccountController.cs b/samples/IdentitySample.Mvc/Controllers/AccountController.cs index 24e3aa4759..c9ec74bf53 100644 --- a/samples/IdentitySample.Mvc/Controllers/AccountController.cs +++ b/samples/IdentitySample.Mvc/Controllers/AccountController.cs @@ -377,6 +377,11 @@ namespace IdentitySample.Controllers return View("Error"); } + if (model.SelectedProvider == "Authenticator") + { + return RedirectToAction(nameof(VerifyAuthenticatorCode), new { ReturnUrl = model.ReturnUrl, RememberMe = model.RememberMe }); + } + // Generate the token and send it var code = await _userManager.GenerateTwoFactorTokenAsync(user, model.SelectedProvider); if (string.IsNullOrWhiteSpace(code)) @@ -444,6 +449,93 @@ namespace IdentitySample.Controllers } } + // + // GET: /Account/VerifyAuthenticatorCode + [HttpGet] + [AllowAnonymous] + public async Task VerifyAuthenticatorCode(bool rememberMe, string returnUrl = null) + { + // Require that the user has already logged in via username/password or external login + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + return View("Error"); + } + return View(new VerifyAuthenticatorCodeViewModel { ReturnUrl = returnUrl, RememberMe = rememberMe }); + } + + // + // POST: /Account/VerifyAuthenticatorCode + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task VerifyAuthenticatorCode(VerifyAuthenticatorCodeViewModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + // The following code protects for brute force attacks against the two factor codes. + // If a user enters incorrect codes for a specified amount of time then the user account + // will be locked out for a specified amount of time. + var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(model.Code, model.RememberMe, model.RememberBrowser); + if (result.Succeeded) + { + return RedirectToLocal(model.ReturnUrl); + } + if (result.IsLockedOut) + { + _logger.LogWarning(7, "User account locked out."); + return View("Lockout"); + } + else + { + ModelState.AddModelError(string.Empty, "Invalid code."); + return View(model); + } + } + + // + // GET: /Account/UseRecoveryCode + [HttpGet] + [AllowAnonymous] + public async Task UseRecoveryCode(string returnUrl = null) + { + // Require that the user has already logged in via username/password or external login + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + return View("Error"); + } + return View(new UseRecoveryCodeViewModel { ReturnUrl = returnUrl }); + } + + // + // POST: /Account/UseRecoveryCode + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task UseRecoveryCode(UseRecoveryCodeViewModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(model.Code); + if (result.Succeeded) + { + return RedirectToLocal(model.ReturnUrl); + } + else + { + ModelState.AddModelError(string.Empty, "Invalid code."); + return View(model); + } + } + + #region Helpers private void AddErrors(IdentityResult result) diff --git a/samples/IdentitySample.Mvc/Controllers/ManageController.cs b/samples/IdentitySample.Mvc/Controllers/ManageController.cs index 3d3f5146cc..666b97f3bc 100644 --- a/samples/IdentitySample.Mvc/Controllers/ManageController.cs +++ b/samples/IdentitySample.Mvc/Controllers/ManageController.cs @@ -58,7 +58,8 @@ namespace IdentitySamples.Controllers PhoneNumber = await _userManager.GetPhoneNumberAsync(user), TwoFactor = await _userManager.GetTwoFactorEnabledAsync(user), Logins = await _userManager.GetLoginsAsync(user), - BrowserRemembered = await _signInManager.IsTwoFactorClientRememberedAsync(user) + BrowserRemembered = await _signInManager.IsTwoFactorClientRememberedAsync(user), + AuthenticatorKey = await _userManager.GetAuthenticatorKeyAsync(user) }; return View(model); } @@ -107,6 +108,37 @@ namespace IdentitySamples.Controllers return RedirectToAction(nameof(VerifyPhoneNumber), new { PhoneNumber = model.PhoneNumber }); } + // + // POST: /Manage/ResetAuthenticatorKey + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ResetAuthenticatorKey() + { + var user = await GetCurrentUserAsync(); + if (user != null) + { + await _userManager.ResetAuthenticatorKeyAsync(user); + _logger.LogInformation(1, "User reset authenticator key."); + } + return RedirectToAction(nameof(Index), "Manage"); + } + + // + // POST: /Manage/GenerateRecoveryCode + [HttpPost] + [ValidateAntiForgeryToken] + public async Task GenerateRecoveryCode() + { + var user = await GetCurrentUserAsync(); + if (user != null) + { + var codes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 5); + _logger.LogInformation(1, "User generated new recovery code."); + return View("DisplayRecoveryCodes", new DisplayRecoveryCodesViewModel { Codes = codes }); + } + return View("Error"); + } + // // POST: /Manage/EnableTwoFactorAuthentication [HttpPost] diff --git a/samples/IdentitySample.Mvc/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs b/samples/IdentitySample.Mvc/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs index b3f9533f3e..c68785939b 100644 --- a/samples/IdentitySample.Mvc/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs +++ b/samples/IdentitySample.Mvc/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs @@ -1,12 +1,12 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using IdentitySample.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; -using IdentitySample.Models; namespace IdentitySample.Data.Migrations { @@ -17,7 +17,7 @@ namespace IdentitySample.Data.Migrations protected override void BuildTargetModel(ModelBuilder modelBuilder) { modelBuilder - .HasAnnotation("ProductVersion", "7.0.0-rc2") + .HasAnnotation("ProductVersion", "1.0.0-rc3") .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole", b => @@ -36,9 +36,9 @@ namespace IdentitySample.Data.Migrations b.HasKey("Id"); b.HasIndex("NormalizedName") - .HasAnnotation("Relational:Name", "RoleNameIndex"); + .HasName("RoleNameIndex"); - b.HasAnnotation("Relational:TableName", "AspNetRoles"); + b.ToTable("AspNetRoles"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRoleClaim", b => @@ -57,7 +57,7 @@ namespace IdentitySample.Data.Migrations b.HasIndex("RoleId"); - b.HasAnnotation("Relational:TableName", "AspNetRoleClaims"); + b.ToTable("AspNetRoleClaims"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserClaim", b => @@ -76,7 +76,7 @@ namespace IdentitySample.Data.Migrations b.HasIndex("UserId"); - b.HasAnnotation("Relational:TableName", "AspNetUserClaims"); + b.ToTable("AspNetUserClaims"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserLogin", b => @@ -94,7 +94,7 @@ namespace IdentitySample.Data.Migrations b.HasIndex("UserId"); - b.HasAnnotation("Relational:TableName", "AspNetUserLogins"); + b.ToTable("AspNetUserLogins"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserRole", b => @@ -109,10 +109,25 @@ namespace IdentitySample.Data.Migrations b.HasIndex("UserId"); - b.HasAnnotation("Relational:TableName", "AspNetUserRoles"); + b.ToTable("AspNetUserRoles"); }); - modelBuilder.Entity("$safeprojectname$.Models.ApplicationUser", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserToken", b => + { + b.Property("UserId"); + + b.Property("LoginProvider"); + + b.Property("Name"); + + b.Property("Value"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("WebApplication13.Models.ApplicationUser", b => { b.Property("Id"); @@ -152,34 +167,35 @@ namespace IdentitySample.Data.Migrations b.HasKey("Id"); b.HasIndex("NormalizedEmail") - .HasAnnotation("Relational:Name", "EmailIndex"); + .HasName("EmailIndex"); b.HasIndex("NormalizedUserName") - .HasAnnotation("Relational:Name", "UserNameIndex"); + .IsUnique() + .HasName("UserNameIndex"); - b.HasAnnotation("Relational:TableName", "AspNetUsers"); + b.ToTable("AspNetUsers"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole") - .WithMany() + .WithMany("Claims") .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserClaim", b => { - b.HasOne("$safeprojectname$.Models.ApplicationUser") - .WithMany() + b.HasOne("WebApplication13.Models.ApplicationUser") + .WithMany("Claims") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserLogin", b => { - b.HasOne("$safeprojectname$.Models.ApplicationUser") - .WithMany() + b.HasOne("WebApplication13.Models.ApplicationUser") + .WithMany("Logins") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade); }); @@ -187,12 +203,12 @@ namespace IdentitySample.Data.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserRole", b => { b.HasOne("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole") - .WithMany() + .WithMany("Users") .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade); - b.HasOne("$safeprojectname$.Models.ApplicationUser") - .WithMany() + b.HasOne("WebApplication13.Models.ApplicationUser") + .WithMany("Roles") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade); }); diff --git a/samples/IdentitySample.Mvc/Data/Migrations/00000000000000_CreateIdentitySchema.cs b/samples/IdentitySample.Mvc/Data/Migrations/00000000000000_CreateIdentitySchema.cs index 3632fc51b7..7233e2d3bc 100644 --- a/samples/IdentitySample.Mvc/Data/Migrations/00000000000000_CreateIdentitySchema.cs +++ b/samples/IdentitySample.Mvc/Data/Migrations/00000000000000_CreateIdentitySchema.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -17,14 +17,28 @@ namespace IdentitySample.Data.Migrations { Id = table.Column(nullable: false), ConcurrencyStamp = table.Column(nullable: true), - Name = table.Column(nullable: true), - NormalizedName = table.Column(nullable: true) + Name = table.Column(maxLength: 256, nullable: true), + NormalizedName = table.Column(maxLength: 256, nullable: true) }, constraints: table => { table.PrimaryKey("PK_AspNetRoles", x => x.Id); }); + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(nullable: false), + LoginProvider = table.Column(nullable: false), + Name = table.Column(nullable: false), + Value = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + }); + migrationBuilder.CreateTable( name: "AspNetUsers", columns: table => new @@ -32,18 +46,18 @@ namespace IdentitySample.Data.Migrations Id = table.Column(nullable: false), AccessFailedCount = table.Column(nullable: false), ConcurrencyStamp = table.Column(nullable: true), - Email = table.Column(nullable: true), + Email = table.Column(maxLength: 256, nullable: true), EmailConfirmed = table.Column(nullable: false), LockoutEnabled = table.Column(nullable: false), LockoutEnd = table.Column(nullable: true), - NormalizedEmail = table.Column(nullable: true), - NormalizedUserName = table.Column(nullable: true), + NormalizedEmail = table.Column(maxLength: 256, nullable: true), + NormalizedUserName = table.Column(maxLength: 256, nullable: true), PasswordHash = table.Column(nullable: true), PhoneNumber = table.Column(nullable: true), PhoneNumberConfirmed = table.Column(nullable: false), SecurityStamp = table.Column(nullable: true), TwoFactorEnabled = table.Column(nullable: false), - UserName = table.Column(nullable: true) + UserName = table.Column(maxLength: 256, nullable: true) }, constraints: table => { @@ -174,7 +188,8 @@ namespace IdentitySample.Data.Migrations migrationBuilder.CreateIndex( name: "UserNameIndex", table: "AspNetUsers", - column: "NormalizedUserName"); + column: "NormalizedUserName", + unique: true); } protected override void Down(MigrationBuilder migrationBuilder) @@ -191,6 +206,9 @@ namespace IdentitySample.Data.Migrations migrationBuilder.DropTable( name: "AspNetUserRoles"); + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + migrationBuilder.DropTable( name: "AspNetRoles"); diff --git a/samples/IdentitySample.Mvc/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/samples/IdentitySample.Mvc/Data/Migrations/ApplicationDbContextModelSnapshot.cs index de0a89cc0b..ff4714e16a 100644 --- a/samples/IdentitySample.Mvc/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/samples/IdentitySample.Mvc/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1,12 +1,12 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using IdentitySample.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; -using IdentitySample.Models; namespace IdentitySample.Data.Migrations { @@ -16,7 +16,7 @@ namespace IdentitySample.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { modelBuilder - .HasAnnotation("ProductVersion", "7.0.0-rc2") + .HasAnnotation("ProductVersion", "1.0.0-rc3") .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole", b => @@ -35,9 +35,9 @@ namespace IdentitySample.Data.Migrations b.HasKey("Id"); b.HasIndex("NormalizedName") - .HasAnnotation("Relational:Name", "RoleNameIndex"); + .HasName("RoleNameIndex"); - b.HasAnnotation("Relational:TableName", "AspNetRoles"); + b.ToTable("AspNetRoles"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRoleClaim", b => @@ -56,7 +56,7 @@ namespace IdentitySample.Data.Migrations b.HasIndex("RoleId"); - b.HasAnnotation("Relational:TableName", "AspNetRoleClaims"); + b.ToTable("AspNetRoleClaims"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserClaim", b => @@ -75,7 +75,7 @@ namespace IdentitySample.Data.Migrations b.HasIndex("UserId"); - b.HasAnnotation("Relational:TableName", "AspNetUserClaims"); + b.ToTable("AspNetUserClaims"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserLogin", b => @@ -93,7 +93,7 @@ namespace IdentitySample.Data.Migrations b.HasIndex("UserId"); - b.HasAnnotation("Relational:TableName", "AspNetUserLogins"); + b.ToTable("AspNetUserLogins"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserRole", b => @@ -108,10 +108,25 @@ namespace IdentitySample.Data.Migrations b.HasIndex("UserId"); - b.HasAnnotation("Relational:TableName", "AspNetUserRoles"); + b.ToTable("AspNetUserRoles"); }); - modelBuilder.Entity("$safeprojectname$.Models.ApplicationUser", b => + modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserToken", b => + { + b.Property("UserId"); + + b.Property("LoginProvider"); + + b.Property("Name"); + + b.Property("Value"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("IdentitySample.Models.ApplicationUser", b => { b.Property("Id"); @@ -151,34 +166,35 @@ namespace IdentitySample.Data.Migrations b.HasKey("Id"); b.HasIndex("NormalizedEmail") - .HasAnnotation("Relational:Name", "EmailIndex"); + .HasName("EmailIndex"); b.HasIndex("NormalizedUserName") - .HasAnnotation("Relational:Name", "UserNameIndex"); + .IsUnique() + .HasName("UserNameIndex"); - b.HasAnnotation("Relational:TableName", "AspNetUsers"); + b.ToTable("AspNetUsers"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole") - .WithMany() + .WithMany("Claims") .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserClaim", b => { - b.HasOne("$safeprojectname$.Models.ApplicationUser") - .WithMany() + b.HasOne("IdentitySample.Models.ApplicationUser") + .WithMany("Claims") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserLogin", b => { - b.HasOne("$safeprojectname$.Models.ApplicationUser") - .WithMany() + b.HasOne("IdentitySample.Models.ApplicationUser") + .WithMany("Logins") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade); }); @@ -186,12 +202,12 @@ namespace IdentitySample.Data.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserRole", b => { b.HasOne("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole") - .WithMany() + .WithMany("Users") .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade); - b.HasOne("$safeprojectname$.Models.ApplicationUser") - .WithMany() + b.HasOne("IdentitySample.Models.ApplicationUser") + .WithMany("Roles") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade); }); diff --git a/samples/IdentitySample.Mvc/Models/AccountViewModels/UseRecoveryCodeViewModel.cs b/samples/IdentitySample.Mvc/Models/AccountViewModels/UseRecoveryCodeViewModel.cs new file mode 100644 index 0000000000..f3b6027557 --- /dev/null +++ b/samples/IdentitySample.Mvc/Models/AccountViewModels/UseRecoveryCodeViewModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace IdentitySample.Models.AccountViewModels +{ + public class UseRecoveryCodeViewModel + { + [Required] + public string Code { get; set; } + + public string ReturnUrl { get; set; } + } +} diff --git a/samples/IdentitySample.Mvc/Models/AccountViewModels/VerifyAuthenticatorCodeViewModel.cs b/samples/IdentitySample.Mvc/Models/AccountViewModels/VerifyAuthenticatorCodeViewModel.cs new file mode 100644 index 0000000000..790d841f15 --- /dev/null +++ b/samples/IdentitySample.Mvc/Models/AccountViewModels/VerifyAuthenticatorCodeViewModel.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace IdentitySample.Models.AccountViewModels +{ + public class VerifyAuthenticatorCodeViewModel + { + [Required] + public string Code { get; set; } + + public string ReturnUrl { get; set; } + + [Display(Name = "Remember this browser?")] + public bool RememberBrowser { get; set; } + + [Display(Name = "Remember me?")] + public bool RememberMe { get; set; } + } +} diff --git a/samples/IdentitySample.Mvc/Models/AccountViewModels/VerifyCodeViewModel.cs b/samples/IdentitySample.Mvc/Models/AccountViewModels/VerifyCodeViewModel.cs index df6dce2717..caf3457f4a 100644 --- a/samples/IdentitySample.Mvc/Models/AccountViewModels/VerifyCodeViewModel.cs +++ b/samples/IdentitySample.Mvc/Models/AccountViewModels/VerifyCodeViewModel.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Threading.Tasks; +using System.ComponentModel.DataAnnotations; namespace IdentitySample.Models.AccountViewModels { diff --git a/samples/IdentitySample.Mvc/Models/ManageViewModels/DisplayRecoveryCodesViewModel.cs b/samples/IdentitySample.Mvc/Models/ManageViewModels/DisplayRecoveryCodesViewModel.cs new file mode 100644 index 0000000000..cd91aa2b79 --- /dev/null +++ b/samples/IdentitySample.Mvc/Models/ManageViewModels/DisplayRecoveryCodesViewModel.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace IdentitySample.Models.ManageViewModels +{ + public class DisplayRecoveryCodesViewModel + { + [Required] + public IEnumerable Codes { get; set; } + + } +} diff --git a/samples/IdentitySample.Mvc/Models/ManageViewModels/IndexViewModel.cs b/samples/IdentitySample.Mvc/Models/ManageViewModels/IndexViewModel.cs index 09ce077c84..cc874ae833 100644 --- a/samples/IdentitySample.Mvc/Models/ManageViewModels/IndexViewModel.cs +++ b/samples/IdentitySample.Mvc/Models/ManageViewModels/IndexViewModel.cs @@ -17,5 +17,7 @@ namespace IdentitySample.Models.ManageViewModels public bool TwoFactor { get; set; } public bool BrowserRemembered { get; set; } + + public string AuthenticatorKey { get; set; } } } diff --git a/samples/IdentitySample.Mvc/Program.cs b/samples/IdentitySample.Mvc/Program.cs index b186e6812d..03110954a3 100644 --- a/samples/IdentitySample.Mvc/Program.cs +++ b/samples/IdentitySample.Mvc/Program.cs @@ -1,6 +1,5 @@ using System.IO; using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; namespace IdentitySample { @@ -8,11 +7,8 @@ namespace IdentitySample { public static void Main(string[] args) { - var config = new ConfigurationBuilder().AddEnvironmentVariables("ASPNETCORE_").Build(); - var host = new WebHostBuilder() .UseKestrel() - .UseConfiguration(config) .UseContentRoot(Directory.GetCurrentDirectory()) .UseIISIntegration() .UseStartup() diff --git a/samples/IdentitySample.Mvc/Properties/launchSettings.json b/samples/IdentitySample.Mvc/Properties/launchSettings.json index 485fe8532f..cef8aeebde 100644 --- a/samples/IdentitySample.Mvc/Properties/launchSettings.json +++ b/samples/IdentitySample.Mvc/Properties/launchSettings.json @@ -12,7 +12,7 @@ "commandName": "IISExpress", "launchBrowser": true, "environmentVariables": { - "ASPNET_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development" } }, "web": { diff --git a/samples/IdentitySample.Mvc/Startup.cs b/samples/IdentitySample.Mvc/Startup.cs index 4d86e0d3b1..f61d83ac21 100644 --- a/samples/IdentitySample.Mvc/Startup.cs +++ b/samples/IdentitySample.Mvc/Startup.cs @@ -21,18 +21,11 @@ namespace IdentitySample { public Startup(IHostingEnvironment env) { - // Set up configuration sources. var builder = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json") + .SetBasePath(env.ContentRootPath) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true); - if (env.IsDevelopment()) - { - // For more details on using the user secret store see http://go.microsoft.com/fwlink/?LinkID=532709 - builder.AddUserSecrets(); - } - builder.AddEnvironmentVariables(); Configuration = builder.Build(); } @@ -44,13 +37,9 @@ namespace IdentitySample { // Add framework services. services.AddDbContext(options => - options.UseSqlServer(Configuration["Data:DefaultConnection:ConnectionString"])); + options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); - services.AddIdentity(options => { - options.Cookies.ApplicationCookie.AuthenticationScheme = "ApplicationCookie"; - options.Cookies.ApplicationCookie.CookieName = "Interop"; - options.Cookies.ApplicationCookie.DataProtectionProvider = DataProtectionProvider.Create(new DirectoryInfo("C:\\Github\\Identity\\artifacts")); - }) + services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); @@ -75,19 +64,8 @@ namespace IdentitySample else { app.UseExceptionHandler("/Home/Error"); - - // For more details on creating database during deployment see http://go.microsoft.com/fwlink/?LinkID=615859 - try - { - using (var serviceScope = app.ApplicationServices.GetRequiredService() - .CreateScope()) - { - serviceScope.ServiceProvider.GetService() - .Database.Migrate(); - } - } - catch { } } + app.UseStaticFiles(); app.UseIdentity(); diff --git a/samples/IdentitySample.Mvc/Views/Account/UseRecoveryCode.cshtml b/samples/IdentitySample.Mvc/Views/Account/UseRecoveryCode.cshtml new file mode 100644 index 0000000000..0b16cba7b2 --- /dev/null +++ b/samples/IdentitySample.Mvc/Views/Account/UseRecoveryCode.cshtml @@ -0,0 +1,28 @@ +@model UseRecoveryCodeViewModel +@{ + ViewData["Title"] = "Use recovery code"; +} + +

@ViewData["Title"].

+ +
+
+

@ViewData["Status"]

+
+
+ +
+ + +
+
+
+
+ +
+
+
+ +@section Scripts { + @{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); } +} diff --git a/samples/IdentitySample.Mvc/Views/Account/VerifyAuthenticatorCode.cshtml b/samples/IdentitySample.Mvc/Views/Account/VerifyAuthenticatorCode.cshtml new file mode 100644 index 0000000000..a122eae8fa --- /dev/null +++ b/samples/IdentitySample.Mvc/Views/Account/VerifyAuthenticatorCode.cshtml @@ -0,0 +1,40 @@ +@model VerifyAuthenticatorCodeViewModel +@{ + ViewData["Title"] = "Verify"; +} + +

@ViewData["Title"].

+ +
+
+ +

@ViewData["Status"]

+
+
+ +
+ + +
+
+
+
+
+ + +
+
+
+
+
+ +
+
+

+ Lost your authenticator? +

+
+ +@section Scripts { + @{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); } +} diff --git a/samples/IdentitySample.Mvc/Views/Manage/DisplayRecoveryCodes.cshtml b/samples/IdentitySample.Mvc/Views/Manage/DisplayRecoveryCodes.cshtml new file mode 100644 index 0000000000..00df7b2d60 --- /dev/null +++ b/samples/IdentitySample.Mvc/Views/Manage/DisplayRecoveryCodes.cshtml @@ -0,0 +1,21 @@ +@model DisplayRecoveryCodesViewModel +@{ + ViewData["Title"] = "Your recovery codes:"; +} + +

@ViewData["Title"].

+

@ViewData["StatusMessage"]

+ +
+

Here are your new recovery codes

+
+
+
Codes:
+ @foreach (var code in Model.Codes) + { +
+ @code +
+ } +
+
\ No newline at end of file diff --git a/samples/IdentitySample.Mvc/Views/Manage/Index.cshtml b/samples/IdentitySample.Mvc/Views/Manage/Index.cshtml index 79a2d97ddb..6e6ac54fa7 100644 --- a/samples/IdentitySample.Mvc/Views/Manage/Index.cshtml +++ b/samples/IdentitySample.Mvc/Views/Manage/Index.cshtml @@ -32,7 +32,7 @@ See this article for details on setting up this ASP.NET application to support two-factor authentication using SMS.

- @*@(Model.PhoneNumber ?? "None") + @(Model.PhoneNumber ?? "None") @if (Model.PhoneNumber != null) {
@@ -44,16 +44,16 @@ else { [  Add  ] - }*@ + }
Two-Factor Authentication:
-

+ + @if (Model.TwoFactor) {

Enabled [] @@ -64,7 +64,23 @@ [] Disabled
- }*@ + } +
+
Authentication App:
+
+ @if (Model.AuthenticatorKey == null) + { +
+ Generate [] +
+ } + else + { + Your key is: @Model.AuthenticatorKey +
+ Generate [] +
+ }
\ No newline at end of file diff --git a/samples/IdentitySample.Mvc/Views/Shared/_Layout.cshtml b/samples/IdentitySample.Mvc/Views/Shared/_Layout.cshtml index c17782f2bb..15c0be6c6a 100644 --- a/samples/IdentitySample.Mvc/Views/Shared/_Layout.cshtml +++ b/samples/IdentitySample.Mvc/Views/Shared/_Layout.cshtml @@ -31,7 +31,7 @@ @RenderBody()
-

© $year$ - $safeprojectname$

+

© 2016 - IdentitySample

diff --git a/samples/IdentitySample.Mvc/appsettings.json b/samples/IdentitySample.Mvc/appsettings.json index fb243cd65f..b8be7f3159 100644 --- a/samples/IdentitySample.Mvc/appsettings.json +++ b/samples/IdentitySample.Mvc/appsettings.json @@ -1,8 +1,6 @@ { - "Data": { - "DefaultConnection": { - "ConnectionString": "Server=(localdb)\\mssqllocaldb;Database=Interop-Shared;Trusted_Connection=True;MultipleActiveResultSets=true" - } + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=Interop-Shared-10-10;Trusted_Connection=True;MultipleActiveResultSets=true" }, "Logging": { "IncludeScopes": false, diff --git a/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/UserStore.cs b/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/UserStore.cs index b8439aad6c..f835fd7a46 100644 --- a/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/UserStore.cs +++ b/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/UserStore.cs @@ -201,7 +201,9 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore IUserPhoneNumberStore, IQueryableUserStore, IUserTwoFactorStore, - IUserAuthenticationTokenStore + IUserAuthenticationTokenStore, + IUserAuthenticatorKeyStore, + IUserTwoFactorRecoveryCodeStore where TUser : IdentityUser where TRole : IdentityRole where TContext : DbContext @@ -1411,7 +1413,7 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore /// The name of the token. /// The used to propagate notifications that the operation should be canceled. /// The that represents the asynchronous operation. - public async virtual Task RemoveTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) + public virtual async Task RemoveTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); @@ -1435,7 +1437,7 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore /// The name of the token. /// The used to propagate notifications that the operation should be canceled. /// The that represents the asynchronous operation. - public async virtual Task GetTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) + public virtual async Task GetTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); @@ -1447,5 +1449,78 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore var entry = await FindToken(user, loginProvider, name, cancellationToken); return entry?.Value; } + + private const string InternalLoginProvider = "[AspNetUserStore]"; + private const string AuthenticatorKeyTokenName = "AuthenticatorKey"; + private const string RecoveryCodeTokenName = "RecoveryCodes"; + + /// + /// Sets the authenticator key for the specified . + /// + /// The user whose authenticator key should be set. + /// The authenticator key to set. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task SetAuthenticatorKeyAsync(TUser user, string key, CancellationToken cancellationToken) + { + return SetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, key, cancellationToken); + } + + /// + /// Get the authenticator key for the specified . + /// + /// The user whose security stamp should be set. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the security stamp for the specified . + public virtual Task GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken) + { + return GetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, cancellationToken); + } + + /// + /// Updates the recovery codes for the user while invalidating any previous recovery codes. + /// + /// The user to store new recovery codes for. + /// The new recovery codes for the user. + /// The used to propagate notifications that the operation should be canceled. + /// The new recovery codes for the user. + public virtual Task ReplaceCodesAsync(TUser user, IEnumerable recoveryCodes, CancellationToken cancellationToken) + { + var mergedCodes = string.Join(";", recoveryCodes); + return SetTokenAsync(user, InternalLoginProvider, RecoveryCodeTokenName, mergedCodes, cancellationToken); + } + + /// + /// Returns whether a recovery code is valid for a user. Note: recovery codes are only valid + /// once, and will be invalid after use. + /// + /// The user who owns the recovery code. + /// The recovery code to use. + /// The used to propagate notifications that the operation should be canceled. + /// True if the recovery code was found for the user. + public virtual async Task RedeemCodeAsync(TUser user, string code, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + if (code == null) + { + throw new ArgumentNullException(nameof(code)); + } + + var mergedCodes = await GetTokenAsync(user, InternalLoginProvider, RecoveryCodeTokenName, cancellationToken) ?? ""; + var splitCodes = mergedCodes.Split(';'); + if (splitCodes.Contains(code)) + { + var updatedCodes = new List(splitCodes.Where(s => s != code)); + await ReplaceCodesAsync(user, updatedCodes, cancellationToken); + return true; + } + return false; + } } } diff --git a/src/Microsoft.AspNetCore.Identity.Specification.Tests/IdentitySpecificationTestBase.cs b/src/Microsoft.AspNetCore.Identity.Specification.Tests/IdentitySpecificationTestBase.cs index 0c43c7e39c..70d17ee256 100644 --- a/src/Microsoft.AspNetCore.Identity.Specification.Tests/IdentitySpecificationTestBase.cs +++ b/src/Microsoft.AspNetCore.Identity.Specification.Tests/IdentitySpecificationTestBase.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Security.Claims; +using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -13,6 +14,7 @@ using Microsoft.AspNetCore.Testing; using Microsoft.AspNetCore.Testing.xunit; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Xunit; namespace Microsoft.AspNetCore.Identity.Test @@ -2371,6 +2373,69 @@ namespace Microsoft.AspNetCore.Identity.Test Assert.Null(await manager.GetAuthenticationTokenAsync(user, "provider", "name")); } + /// + /// Test. + /// + /// Task + [Fact] + public async Task CanRedeemRecoveryCodeOnlyOnce() + { + if (ShouldSkipDbTests()) + { + return; + } + var manager = CreateManager(); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + var numCodes = 15; + var newCodes = await manager.GenerateNewTwoFactorRecoveryCodesAsync(user, numCodes); + Assert.Equal(numCodes, newCodes.Count()); + + foreach (var code in newCodes) + { + IdentityResultAssert.IsSuccess(await manager.RedeemTwoFactorRecoveryCodeAsync(user, code)); + IdentityResultAssert.IsFailure(await manager.RedeemTwoFactorRecoveryCodeAsync(user, code)); + } + // One last time to be sure + foreach (var code in newCodes) + { + IdentityResultAssert.IsFailure(await manager.RedeemTwoFactorRecoveryCodeAsync(user, code)); + } + } + + /// + /// Test. + /// + /// Task + [Fact] + public async Task RecoveryCodesInvalidAfterReplace() + { + if (ShouldSkipDbTests()) + { + return; + } + var manager = CreateManager(); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + var numCodes = 15; + var newCodes = await manager.GenerateNewTwoFactorRecoveryCodesAsync(user, numCodes); + Assert.Equal(numCodes, newCodes.Count()); + var realCodes = await manager.GenerateNewTwoFactorRecoveryCodesAsync(user, numCodes); + Assert.Equal(numCodes, realCodes.Count()); + + foreach (var code in newCodes) + { + IdentityResultAssert.IsFailure(await manager.RedeemTwoFactorRecoveryCodeAsync(user, code)); + } + + foreach (var code in realCodes) + { + IdentityResultAssert.IsSuccess(await manager.RedeemTwoFactorRecoveryCodeAsync(user, code)); + } + } + /// /// Test. /// @@ -2408,6 +2473,11 @@ namespace Microsoft.AspNetCore.Identity.Test Assert.NotNull(factors); Assert.Equal(1, factors.Count()); Assert.Equal("Phone", factors[0]); + IdentityResultAssert.IsSuccess(await manager.ResetAuthenticatorKeyAsync(user)); + factors = await manager.GetValidTwoFactorProvidersAsync(user); + Assert.NotNull(factors); + Assert.Equal(2, factors.Count()); + Assert.Equal("Authenticator", factors[1]); } /// diff --git a/src/Microsoft.AspNetCore.Identity/AuthenticatorTokenprovider.cs b/src/Microsoft.AspNetCore.Identity/AuthenticatorTokenprovider.cs new file mode 100644 index 0000000000..afc57eb73b --- /dev/null +++ b/src/Microsoft.AspNetCore.Identity/AuthenticatorTokenprovider.cs @@ -0,0 +1,71 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Security.Cryptography; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity +{ + /// + /// Used for authenticator code verification. + /// + public class AuthenticatorTokenProvider : IUserTwoFactorTokenProvider where TUser : class + { + /// + /// Checks if a two factor authentication token can be generated for the specified . + /// + /// The to retrieve the from. + /// The to check for the possibility of generating a two factor authentication token. + /// True if the user has an authenticator key set, otherwise false. + public async virtual Task CanGenerateTwoFactorTokenAsync(UserManager manager, TUser user) + { + var key = await manager.GetAuthenticatorKeyAsync(user); + return !string.IsNullOrWhiteSpace(key); + } + + /// + /// Returns an empty string since no authenticator codes are sent. + /// + /// Ignored. + /// The to retrieve the from. + /// The . + /// string.Empty. + public virtual Task GenerateAsync(string purpose, UserManager manager, TUser user) + { + return Task.FromResult(string.Empty); + } + + /// + /// + /// + /// + /// + /// + /// + /// + public virtual async Task ValidateAsync(string purpose, string token, UserManager manager, TUser user) + { + var key = await manager.GetAuthenticatorKeyAsync(user); + int code; + if (!int.TryParse(token, out code)) + { + return false; + } + + var hash = new HMACSHA1(Base32.FromBase32(key)); + var unixTimestamp = Convert.ToInt64(Math.Round((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds)); + var timestep = Convert.ToInt64(unixTimestamp / 30); + // Allow codes from 90s in each direction (we could make this configurable?) + for (int i = -2; i <= 2; i++) + { + var expectedCode = Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifier: null); + if (expectedCode == code) + { + return true; + } + } + return false; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Identity/Base32.cs b/src/Microsoft.AspNetCore.Identity/Base32.cs new file mode 100644 index 0000000000..579a58feac --- /dev/null +++ b/src/Microsoft.AspNetCore.Identity/Base32.cs @@ -0,0 +1,119 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Text; + +namespace Microsoft.AspNetCore.Identity +{ + // See http://tools.ietf.org/html/rfc3548#section-5 + internal static class Base32 + { + private static readonly string _base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + + public static string ToBase32(byte[] input) + { + if (input == null) + { + throw new ArgumentNullException(nameof(input)); + } + + StringBuilder sb = new StringBuilder(); + for (int offset = 0; offset < input.Length;) + { + byte a, b, c, d, e, f, g, h; + int numCharsToOutput = GetNextGroup(input, ref offset, out a, out b, out c, out d, out e, out f, out g, out h); + + sb.Append((numCharsToOutput >= 1) ? _base32Chars[a] : '='); + sb.Append((numCharsToOutput >= 2) ? _base32Chars[b] : '='); + sb.Append((numCharsToOutput >= 3) ? _base32Chars[c] : '='); + sb.Append((numCharsToOutput >= 4) ? _base32Chars[d] : '='); + sb.Append((numCharsToOutput >= 5) ? _base32Chars[e] : '='); + sb.Append((numCharsToOutput >= 6) ? _base32Chars[f] : '='); + sb.Append((numCharsToOutput >= 7) ? _base32Chars[g] : '='); + sb.Append((numCharsToOutput >= 8) ? _base32Chars[h] : '='); + } + + return sb.ToString(); + } + + public static byte[] FromBase32(string input) + { + if (input == null) + { + throw new ArgumentNullException(nameof(input)); + } + input = input.TrimEnd('=').ToUpperInvariant(); + if (input.Length == 0) + { + return new byte[0]; + } + + var output = new byte[input.Length * 5 / 8]; + var bitIndex = 0; + var inputIndex = 0; + var outputBits = 0; + var outputIndex = 0; + while (outputIndex < output.Length) + { + var byteIndex = _base32Chars.IndexOf(input[inputIndex]); + if (byteIndex < 0) + { + throw new FormatException(); + } + + var bits = Math.Min(5 - bitIndex, 8 - outputBits); + output[outputIndex] <<= bits; + output[outputIndex] |= (byte)(byteIndex >> (5 - (bitIndex + bits))); + + bitIndex += bits; + if (bitIndex >= 5) + { + inputIndex++; + bitIndex = 0; + } + + outputBits += bits; + if (outputBits >= 8) + { + outputIndex++; + outputBits = 0; + } + } + return output; + } + + // returns the number of bytes that were output + private static int GetNextGroup(byte[] input, ref int offset, out byte a, out byte b, out byte c, out byte d, out byte e, out byte f, out byte g, out byte h) + { + uint b1, b2, b3, b4, b5; + + int retVal; + switch (offset - input.Length) + { + case 1: retVal = 2; break; + case 2: retVal = 4; break; + case 3: retVal = 5; break; + case 4: retVal = 7; break; + default: retVal = 8; break; + } + + b1 = (offset < input.Length) ? input[offset++] : 0U; + b2 = (offset < input.Length) ? input[offset++] : 0U; + b3 = (offset < input.Length) ? input[offset++] : 0U; + b4 = (offset < input.Length) ? input[offset++] : 0U; + b5 = (offset < input.Length) ? input[offset++] : 0U; + + a = (byte)(b1 >> 3); + b = (byte)(((b1 & 0x07) << 2) | (b2 >> 6)); + c = (byte)((b2 >> 1) & 0x1f); + d = (byte)(((b2 & 0x01) << 4) | (b3 >> 4)); + e = (byte)(((b3 & 0x0f) << 1) | (b4 >> 7)); + f = (byte)((b4 >> 2) & 0x1f); + g = (byte)(((b4 & 0x3) << 3) | (b5 >> 5)); + h = (byte)(b5 & 0x1f); + + return retVal; + } + } +} diff --git a/src/Microsoft.AspNetCore.Identity/IUserAuthenticatorInfoStore.cs b/src/Microsoft.AspNetCore.Identity/IUserAuthenticatorInfoStore.cs new file mode 100644 index 0000000000..413ac21c18 --- /dev/null +++ b/src/Microsoft.AspNetCore.Identity/IUserAuthenticatorInfoStore.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity +{ + /// + /// Provides an abstraction for a store which stores info about user's authenticator. + /// + /// The type encapsulating a user. + public interface IUserAuthenticatorKeyStore : IUserStore where TUser : class + { + /// + /// Sets the authenticator key for the specified . + /// + /// The user whose authenticator key should be set. + /// The authenticator key to set. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + Task SetAuthenticatorKeyAsync(TUser user, string key, CancellationToken cancellationToken); + + /// + /// Get the authenticator key for the specified . + /// + /// The user whose security stamp should be set. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the security stamp for the specified . + Task GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Identity/IUserTwoFactorRecoveryCodeStore.cs b/src/Microsoft.AspNetCore.Identity/IUserTwoFactorRecoveryCodeStore.cs new file mode 100644 index 0000000000..fdc1544b13 --- /dev/null +++ b/src/Microsoft.AspNetCore.Identity/IUserTwoFactorRecoveryCodeStore.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity +{ + /// + /// Provides an abstraction for a store which stores a user's recovery codes. + /// + /// The type encapsulating a user. + public interface IUserTwoFactorRecoveryCodeStore : IUserStore where TUser : class + { + /// + /// Updates the recovery codes for the user while invalidating any previous recovery codes. + /// + /// The user to store new recovery codes for. + /// The new recovery codes for the user. + /// The used to propagate notifications that the operation should be canceled. + /// The new recovery codes for the user. + Task ReplaceCodesAsync(TUser user, IEnumerable recoveryCodes, CancellationToken cancellationToken); + + /// + /// Returns whether a recovery code is valid for a user. Note: recovery codes are only valid + /// once, and will be invalid after use. + /// + /// The user who owns the recovery code. + /// The recovery code to use. + /// The used to propagate notifications that the operation should be canceled. + /// True if the recovery code was found for the user. + Task RedeemCodeAsync(TUser user, string code, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Identity/IdentityBuilder.cs b/src/Microsoft.AspNetCore.Identity/IdentityBuilder.cs index 74252d1156..5c754b662f 100644 --- a/src/Microsoft.AspNetCore.Identity/IdentityBuilder.cs +++ b/src/Microsoft.AspNetCore.Identity/IdentityBuilder.cs @@ -169,9 +169,11 @@ namespace Microsoft.AspNetCore.Identity var dataProtectionProviderType = typeof(DataProtectorTokenProvider<>).MakeGenericType(UserType); var phoneNumberProviderType = typeof(PhoneNumberTokenProvider<>).MakeGenericType(UserType); var emailTokenProviderType = typeof(EmailTokenProvider<>).MakeGenericType(UserType); + var authenticatorProviderType = typeof(AuthenticatorTokenProvider<>).MakeGenericType(UserType); return AddTokenProvider(TokenOptions.DefaultProvider, dataProtectionProviderType) .AddTokenProvider(TokenOptions.DefaultEmailProvider, emailTokenProviderType) - .AddTokenProvider(TokenOptions.DefaultPhoneProvider, phoneNumberProviderType); + .AddTokenProvider(TokenOptions.DefaultPhoneProvider, phoneNumberProviderType) + .AddTokenProvider(TokenOptions.DefaultAuthenticatorProvider, authenticatorProviderType); } /// diff --git a/src/Microsoft.AspNetCore.Identity/IdentityErrorDescriber.cs b/src/Microsoft.AspNetCore.Identity/IdentityErrorDescriber.cs index 914e82e5ea..c4df7570ed 100644 --- a/src/Microsoft.AspNetCore.Identity/IdentityErrorDescriber.cs +++ b/src/Microsoft.AspNetCore.Identity/IdentityErrorDescriber.cs @@ -63,6 +63,19 @@ namespace Microsoft.AspNetCore.Identity }; } + /// + /// Returns an indicating a recovery code was not redeemed. + /// + /// An indicating a recovery code was not redeemed. + public virtual IdentityError RecoveryCodeRedemptionFailed() + { + return new IdentityError + { + Code = nameof(RecoveryCodeRedemptionFailed), + Description = Resources.RecoveryCodeRedemptionFailed + }; + } + /// /// Returns an indicating an external login is already associated with an account. /// diff --git a/src/Microsoft.AspNetCore.Identity/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Identity/Properties/Resources.Designer.cs index fa00dea7f6..75af2a8a22 100644 --- a/src/Microsoft.AspNetCore.Identity/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Identity/Properties/Resources.Designer.cs @@ -426,6 +426,22 @@ namespace Microsoft.AspNetCore.Identity return GetString("StoreNotIRoleClaimStore"); } + /// + /// Store does not implement IUserAuthenticationTokenStore<User>. + /// + internal static string StoreNotIUserAuthenticationTokenStore + { + get { return GetString("StoreNotIUserAuthenticationTokenStore"); } + } + + /// + /// Store does not implement IUserAuthenticationTokenStore<User>. + /// + internal static string FormatStoreNotIUserAuthenticationTokenStore() + { + return GetString("StoreNotIUserAuthenticationTokenStore"); + } + /// /// Store does not implement IUserClaimStore<TUser>. /// @@ -570,6 +586,22 @@ namespace Microsoft.AspNetCore.Identity return GetString("StoreNotIUserSecurityStampStore"); } + /// + /// Store does not implement IUserAuthenticatorKeyStore<User>. + /// + internal static string StoreNotIUserAuthenticatorKeyStore + { + get { return GetString("StoreNotIUserAuthenticatorKeyStore"); } + } + + /// + /// Store does not implement IUserAuthenticatorKeyStore<User>. + /// + internal static string FormatStoreNotIUserAuthenticatorKeyStore() + { + return GetString("StoreNotIUserAuthenticatorKeyStore"); + } + /// /// Store does not implement IUserTwoFactorStore<TUser>. /// @@ -586,6 +618,22 @@ namespace Microsoft.AspNetCore.Identity return GetString("StoreNotIUserTwoFactorStore"); } + /// + /// Recovery code redemption failed. + /// + internal static string RecoveryCodeRedemptionFailed + { + get { return GetString("RecoveryCodeRedemptionFailed"); } + } + + /// + /// Recovery code redemption failed. + /// + internal static string FormatRecoveryCodeRedemptionFailed() + { + return GetString("RecoveryCodeRedemptionFailed"); + } + /// /// User already has a password set. /// @@ -682,6 +730,22 @@ namespace Microsoft.AspNetCore.Identity return string.Format(CultureInfo.CurrentCulture, GetString("UserNotInRole"), p0); } + /// + /// Store does not implement IUserTwoFactorRecoveryCodeStore<User>. + /// + internal static string StoreNotIUserTwoFactorRecoveryCodeStore + { + get { return GetString("StoreNotIUserTwoFactorRecoveryCodeStore"); } + } + + /// + /// Store does not implement IUserTwoFactorRecoveryCodeStore<User>. + /// + internal static string FormatStoreNotIUserTwoFactorRecoveryCodeStore() + { + return GetString("StoreNotIUserTwoFactorRecoveryCodeStore"); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNetCore.Identity/Resources.resx b/src/Microsoft.AspNetCore.Identity/Resources.resx index 3e0512b167..d4cbe1419e 100644 --- a/src/Microsoft.AspNetCore.Identity/Resources.resx +++ b/src/Microsoft.AspNetCore.Identity/Resources.resx @@ -221,6 +221,10 @@ Store does not implement IRoleClaimStore<TRole>. Error when the store does not implement this interface + + Store does not implement IUserAuthenticationTokenStore<User>. + Error when the store does not implement this interface + Store does not implement IUserClaimStore<TUser>. Error when the store does not implement this interface @@ -257,10 +261,18 @@ Store does not implement IUserSecurityStampStore<TUser>. Error when the store does not implement this interface + + Store does not implement IUserAuthenticatorKeyStore<User>. + Error when the store does not implement this interface + Store does not implement IUserTwoFactorStore<TUser>. Error when the store does not implement this interface + + Recovery code redemption failed. + Error when a recovery code is not redeemed. + User already has a password set. Error when AddPasswordAsync called when a user already has a password @@ -285,4 +297,8 @@ User is not in role '{0}'. Error when a user is not in the role + + Store does not implement IUserTwoFactorRecoveryCodeStore<User>. + Error when the store does not implement this interface + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Identity/Rfc6238AuthenticationService.cs b/src/Microsoft.AspNetCore.Identity/Rfc6238AuthenticationService.cs index c286a0fac7..faf92e25f3 100644 --- a/src/Microsoft.AspNetCore.Identity/Rfc6238AuthenticationService.cs +++ b/src/Microsoft.AspNetCore.Identity/Rfc6238AuthenticationService.cs @@ -1,21 +1,31 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; using System.Diagnostics; using System.Net; using System.Security.Cryptography; -using System.Text; namespace Microsoft.AspNetCore.Identity { + using System; + using System.Text; + internal static class Rfc6238AuthenticationService { private static readonly DateTime _unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static readonly TimeSpan _timestep = TimeSpan.FromMinutes(3); private static readonly Encoding _encoding = new UTF8Encoding(false, true); + private static readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); - private static int ComputeTotp(HashAlgorithm hashAlgorithm, ulong timestepNumber, string modifier) + // Generates a new 80-bit security token + public static byte[] GenerateRandomKey() + { + byte[] bytes = new byte[20]; + _rng.GetBytes(bytes); + return bytes; + } + + internal static int ComputeTotp(HashAlgorithm hashAlgorithm, ulong timestepNumber, string modifier) { // # of 0's = length of pin const int Mod = 1000000; diff --git a/src/Microsoft.AspNetCore.Identity/SignInManager.cs b/src/Microsoft.AspNetCore.Identity/SignInManager.cs index 8c278eb972..665bb74276 100644 --- a/src/Microsoft.AspNetCore.Identity/SignInManager.cs +++ b/src/Microsoft.AspNetCore.Identity/SignInManager.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; @@ -362,6 +364,92 @@ namespace Microsoft.AspNetCore.Identity return Context.Authentication.SignOutAsync(Options.Cookies.TwoFactorRememberMeCookieAuthenticationScheme); } + /// + /// Signs in the user without two factor authentication using a two factor recovery code. + /// + /// The two factor recovery code. + /// + public virtual async Task TwoFactorRecoveryCodeSignInAsync(string recoveryCode) + { + var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); + if (twoFactorInfo == null || twoFactorInfo.UserId == null) + { + return SignInResult.Failed; + } + var user = await UserManager.FindByIdAsync(twoFactorInfo.UserId); + if (user == null) + { + return SignInResult.Failed; + } + + var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode); + if (result.Succeeded) + { + await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent: false, rememberClient: false); + return SignInResult.Success; + } + + // We don't protect against brute force attacks since codes are expected to be random. + return SignInResult.Failed; + } + + private async Task DoTwoFactorSignInAsync(TUser user, TwoFactorAuthenticationInfo twoFactorInfo, bool isPersistent, bool rememberClient) + { + // When token is verified correctly, clear the access failed count used for lockout + await ResetLockout(user); + + // Cleanup external cookie + if (twoFactorInfo.LoginProvider != null) + { + await Context.Authentication.SignOutAsync(Options.Cookies.ExternalCookieAuthenticationScheme); + } + // Cleanup two factor user id cookie + await Context.Authentication.SignOutAsync(Options.Cookies.TwoFactorUserIdCookieAuthenticationScheme); + if (rememberClient) + { + await RememberTwoFactorClientAsync(user); + } + await SignInAsync(user, isPersistent, twoFactorInfo.LoginProvider); + } + + /// + /// Validates the sign in code from an authenticator app and creates and signs in the user, as an asynchronous operation. + /// + /// The two factor authentication code to validate. + /// Flag indicating whether the sign-in cookie should persist after the browser is closed. + /// Flag indicating whether the current browser should be remember, suppressing all further + /// two factor authentication prompts. + /// The task object representing the asynchronous operation containing the + /// for the sign-in attempt. + public virtual async Task TwoFactorAuthenticatorSignInAsync(string code, bool isPersistent, bool rememberClient) + { + var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); + if (twoFactorInfo == null || twoFactorInfo.UserId == null) + { + return SignInResult.Failed; + } + var user = await UserManager.FindByIdAsync(twoFactorInfo.UserId); + if (user == null) + { + return SignInResult.Failed; + } + + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + + if (await UserManager.VerifyTwoFactorTokenAsync(user, Options.Tokens.AuthenticatorTokenProvider, code)) + { + await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient); + return SignInResult.Success; + } + // If the token is incorrect, record the failure which also may cause the user to be locked out + await UserManager.AccessFailedAsync(user); + return SignInResult.Failed; + } + /// /// Validates the two faction sign in code and creates and signs in the user, as an asynchronous operation. /// @@ -393,21 +481,7 @@ namespace Microsoft.AspNetCore.Identity } if (await UserManager.VerifyTwoFactorTokenAsync(user, provider, code)) { - // When token is verified correctly, clear the access failed count used for lockout - await ResetLockout(user); - // Cleanup external cookie - if (twoFactorInfo.LoginProvider != null) - { - await Context.Authentication.SignOutAsync(Options.Cookies.ExternalCookieAuthenticationScheme); - } - // Cleanup two factor user id cookie - await Context.Authentication.SignOutAsync(Options.Cookies.TwoFactorUserIdCookieAuthenticationScheme); - if (rememberClient) - { - await RememberTwoFactorClientAsync(user); - } - await UserManager.ResetAccessFailedCountAsync(user); - await SignInAsync(user, isPersistent, twoFactorInfo.LoginProvider); + await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient); return SignInResult.Success; } // If the token is incorrect, record the failure which also may cause the user to be locked out @@ -599,7 +673,6 @@ namespace Microsoft.AspNetCore.Identity return identity; } - private async Task SignInOrTwoFactorAsync(TUser user, bool isPersistent, string loginProvider = null, bool bypassTwoFactor = false) { if (!bypassTwoFactor && diff --git a/src/Microsoft.AspNetCore.Identity/TokenOptions.cs b/src/Microsoft.AspNetCore.Identity/TokenOptions.cs index 8707826f87..d7a23a1b13 100644 --- a/src/Microsoft.AspNetCore.Identity/TokenOptions.cs +++ b/src/Microsoft.AspNetCore.Identity/TokenOptions.cs @@ -26,6 +26,11 @@ namespace Microsoft.AspNetCore.Identity /// public static readonly string DefaultPhoneProvider = "Phone"; + /// + /// Default token provider name used by the . + /// + public static readonly string DefaultAuthenticatorProvider = "Authenticator"; + /// /// Will be used to construct UserTokenProviders with the key used as the providerName. /// @@ -54,5 +59,13 @@ namespace Microsoft.AspNetCore.Identity /// The used to generate tokens used in email change confirmation emails. /// public string ChangeEmailTokenProvider { get; set; } = DefaultProvider; + + /// + /// Gets or sets the used to validate two factor sign ins with an authenticator. + /// + /// + /// The used to validate two factor sign ins with an authenticator. + /// + public string AuthenticatorTokenProvider { get; set; } = DefaultAuthenticatorProvider; } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Identity/UserManager.cs b/src/Microsoft.AspNetCore.Identity/UserManager.cs index ffd67bd867..1ff42833ad 100644 --- a/src/Microsoft.AspNetCore.Identity/UserManager.cs +++ b/src/Microsoft.AspNetCore.Identity/UserManager.cs @@ -158,7 +158,7 @@ namespace Microsoft.AspNetCore.Identity /// Gets a flag indicating whether the backing user store supports authentication tokens. /// /// - /// true if the backing user store supports authentication tokens, otherwise false. + /// true if the backing user store supports authentication tokens, otherwise false. /// public virtual bool SupportsUserAuthenticationTokens { @@ -169,6 +169,36 @@ namespace Microsoft.AspNetCore.Identity } } + /// + /// Gets a flag indicating whether the backing user store supports a user authenticator. + /// + /// + /// true if the backing user store supports a user authenticatior, otherwise false. + /// + public virtual bool SupportsUserAuthenticatorKey + { + get + { + ThrowIfDisposed(); + return Store is IUserAuthenticatorKeyStore; + } + } + + /// + /// Gets a flag indicating whether the backing user store supports recovery codes. + /// + /// + /// true if the backing user store supports a user authenticatior, otherwise false. + /// + public virtual bool SupportsUserTwoFactorRecoveryCodes + { + get + { + ThrowIfDisposed(); + return Store is IUserTwoFactorRecoveryCodeStore; + } + } + /// /// Gets a flag indicating whether the backing user store supports two factor authentication. /// @@ -2013,11 +2043,11 @@ namespace Microsoft.AspNetCore.Identity /// /// The authentication scheme for the provider the token is associated with. /// The name of the token. - /// + /// The authentication token for a user public virtual Task GetAuthenticationTokenAsync(TUser user, string loginProvider, string tokenName) { ThrowIfDisposed(); - var store = GetTokenStore(); + var store = GetAuthenticationTokenStore(); if (user == null) { throw new ArgumentNullException(nameof(user)); @@ -2041,11 +2071,11 @@ namespace Microsoft.AspNetCore.Identity /// The authentication scheme for the provider the token is associated with. /// The name of the token. /// The value of the token. - /// + /// Whether the user was successfully updated. public virtual async Task SetAuthenticationTokenAsync(TUser user, string loginProvider, string tokenName, string tokenValue) { ThrowIfDisposed(); - var store = GetTokenStore(); + var store = GetAuthenticationTokenStore(); if (user == null) { throw new ArgumentNullException(nameof(user)); @@ -2074,7 +2104,7 @@ namespace Microsoft.AspNetCore.Identity public virtual async Task RemoveAuthenticationTokenAsync(TUser user, string loginProvider, string tokenName) { ThrowIfDisposed(); - var store = GetTokenStore(); + var store = GetAuthenticationTokenStore(); if (user == null) { throw new ArgumentNullException(nameof(user)); @@ -2092,6 +2122,110 @@ namespace Microsoft.AspNetCore.Identity return await UpdateUserAsync(user); } + /// + /// Returns the authenticator key for the user. + /// + /// The user. + /// The authenticator key + public virtual Task GetAuthenticatorKeyAsync(TUser user) + { + ThrowIfDisposed(); + var store = GetAuthenticatorKeyStore(); + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + return store.GetAuthenticatorKeyAsync(user, CancellationToken); + } + + /// + /// Resets the authenticator key for the user. + /// + /// The user. + /// Whether the user was successfully updated. + public virtual async Task ResetAuthenticatorKeyAsync(TUser user) + { + ThrowIfDisposed(); + var store = GetAuthenticatorKeyStore(); + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + await store.SetAuthenticatorKeyAsync(user, GenerateNewAuthenticatorKey(), CancellationToken); + return await UpdateAsync(user); + } + + /// + /// Generates a new base32 encoded 160-bit security secret (size of SHA1 hash). + /// + /// The new security secret. + public virtual string GenerateNewAuthenticatorKey() + { + return Base32.ToBase32(Rfc6238AuthenticationService.GenerateRandomKey()); + } + + /// + /// Generates recovery codes for the user, this invalidates any previous recovery codes for the user. + /// + /// The user to generate recovery codes for. + /// The number of codes to generate. + /// The new recovery codes for the user. + public virtual async Task> GenerateNewTwoFactorRecoveryCodesAsync(TUser user, int number) + { + ThrowIfDisposed(); + var store = GetRecoveryCodeStore(); + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + var newCodes = new List(number); + for (var i = 0; i < number; i++) + { + newCodes.Add(CreateTwoFactorRecoveryCode()); + } + + await store.ReplaceCodesAsync(user, newCodes, CancellationToken); + var update = await UpdateAsync(user); + if (update.Succeeded) + { + return newCodes; + } + return null; + } + + /// + /// Generate a new recovery code. + /// + /// + protected virtual string CreateTwoFactorRecoveryCode() + { + return Guid.NewGuid().ToString(); + } + + /// + /// Returns whether a recovery code is valid for a user. Note: recovery codes are only valid + /// once, and will be invalid after use. + /// + /// The user who owns the recovery code. + /// The recovery code to use. + /// True if the recovery code was found for the user. + public virtual async Task RedeemTwoFactorRecoveryCodeAsync(TUser user, string code) + { + ThrowIfDisposed(); + var store = GetRecoveryCodeStore(); + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + var success = await store.RedeemCodeAsync(user, code, CancellationToken); + if (success) + { + return await UpdateAsync(user); + } + return IdentityResult.Failed(ErrorDescriber.RecoveryCodeRedemptionFailed()); + } /// /// Releases the unmanaged resources used by the role manager and optionally releases the managed resources. @@ -2292,12 +2426,32 @@ namespace Microsoft.AspNetCore.Identity return await Store.UpdateAsync(user, CancellationToken); } - private IUserAuthenticationTokenStore GetTokenStore() + private IUserAuthenticatorKeyStore GetAuthenticatorKeyStore() + { + var cast = Store as IUserAuthenticatorKeyStore; + if (cast == null) + { + throw new NotSupportedException(Resources.StoreNotIUserAuthenticatorKeyStore); + } + return cast; + } + + private IUserTwoFactorRecoveryCodeStore GetRecoveryCodeStore() + { + var cast = Store as IUserTwoFactorRecoveryCodeStore; + if (cast == null) + { + throw new NotSupportedException(Resources.StoreNotIUserTwoFactorRecoveryCodeStore); + } + return cast; + } + + private IUserAuthenticationTokenStore GetAuthenticationTokenStore() { var cast = Store as IUserAuthenticationTokenStore; if (cast == null) { - throw new NotSupportedException("Resources.StoreNotIUserAuthenticationTokenStore"); + throw new NotSupportedException(Resources.StoreNotIUserAuthenticationTokenStore); } return cast; } diff --git a/test/Microsoft.AspNetCore.Identity.InMemory.Test/InMemoryStore.cs b/test/Microsoft.AspNetCore.Identity.InMemory.Test/InMemoryStore.cs index d9f67f3851..ea7424fbf3 100644 --- a/test/Microsoft.AspNetCore.Identity.InMemory.Test/InMemoryStore.cs +++ b/test/Microsoft.AspNetCore.Identity.InMemory.Test/InMemoryStore.cs @@ -24,7 +24,9 @@ namespace Microsoft.AspNetCore.Identity.InMemory IUserTwoFactorStore, IQueryableRoleStore, IRoleClaimStore, - IUserAuthenticationTokenStore + IUserAuthenticationTokenStore, + IUserAuthenticatorKeyStore, + IUserTwoFactorRecoveryCodeStore where TRole : TestRole where TUser : TestUser { @@ -548,6 +550,39 @@ namespace Microsoft.AspNetCore.Identity.InMemory return Task.FromResult(tokenEntity?.TokenValue); } + private const string AuthenticatorStoreLoginProvider = "[AspNetAuthenticatorStore]"; + private const string AuthenticatorKeyTokenName = "AuthenticatorKey"; + private const string RecoveryCodeTokenName = "RecoveryCodes"; + + public Task SetAuthenticatorKeyAsync(TUser user, string key, CancellationToken cancellationToken) + { + return SetTokenAsync(user, AuthenticatorStoreLoginProvider, AuthenticatorKeyTokenName, key, cancellationToken); + } + + public Task GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken) + { + return GetTokenAsync(user, AuthenticatorStoreLoginProvider, AuthenticatorKeyTokenName, cancellationToken); + } + + public Task ReplaceCodesAsync(TUser user, IEnumerable recoveryCodes, CancellationToken cancellationToken) + { + var mergedCodes = string.Join(";", recoveryCodes); + return SetTokenAsync(user, AuthenticatorStoreLoginProvider, RecoveryCodeTokenName, mergedCodes, cancellationToken); + } + + public async Task RedeemCodeAsync(TUser user, string code, CancellationToken cancellationToken) + { + var mergedCodes = await GetTokenAsync(user, AuthenticatorStoreLoginProvider, RecoveryCodeTokenName, cancellationToken) ?? ""; + var splitCodes = mergedCodes.Split(';'); + if (splitCodes.Contains(code)) + { + var updatedCodes = new List(splitCodes.Where(s => s != code)); + await ReplaceCodesAsync(user, updatedCodes, cancellationToken); + return true; + } + return false; + } + public IQueryable Roles { get { return _roles.Values.AsQueryable(); } diff --git a/test/Microsoft.AspNetCore.Identity.Test/IdentityBuilderTest.cs b/test/Microsoft.AspNetCore.Identity.Test/IdentityBuilderTest.cs index 03acf210b3..31c9ce27f3 100644 --- a/test/Microsoft.AspNetCore.Identity.Test/IdentityBuilderTest.cs +++ b/test/Microsoft.AspNetCore.Identity.Test/IdentityBuilderTest.cs @@ -140,7 +140,7 @@ namespace Microsoft.AspNetCore.Identity.Test var provider = services.BuildServiceProvider(); var tokenProviders = provider.GetRequiredService>().Value.Tokens.ProviderMap.Values; - Assert.Equal(3, tokenProviders.Count()); + Assert.Equal(4, tokenProviders.Count()); } [Fact] diff --git a/test/Microsoft.AspNetCore.Identity.Test/SignInManagerTest.cs b/test/Microsoft.AspNetCore.Identity.Test/SignInManagerTest.cs index df1ae35d79..c32f8e96ea 100644 --- a/test/Microsoft.AspNetCore.Identity.Test/SignInManagerTest.cs +++ b/test/Microsoft.AspNetCore.Identity.Test/SignInManagerTest.cs @@ -360,6 +360,111 @@ namespace Microsoft.AspNetCore.Identity.Test auth.Verify(); } + private class GoodTokenProvider : AuthenticatorTokenProvider + { + public override Task ValidateAsync(string purpose, string token, UserManager manager, TestUser user) + { + return Task.FromResult(true); + } + } + + + [Theory] + [InlineData(null, true, true)] + [InlineData("Authenticator", false, true)] + [InlineData("Gooblygook", true, false)] + [InlineData("--", false, false)] + public async Task CanTwoFactorAuthenticatorSignIn(string providerName, bool isPersistent, bool rememberClient) + { + // Setup + var user = new TestUser { UserName = "Foo" }; + const string code = "3123"; + var manager = SetupUserManager(user); + manager.Setup(m => m.SupportsUserLockout).Returns(true).Verifiable(); + manager.Setup(m => m.VerifyTwoFactorTokenAsync(user, providerName ?? TokenOptions.DefaultAuthenticatorProvider, code)).ReturnsAsync(true).Verifiable(); + manager.Setup(m => m.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success).Verifiable(); + + var context = new Mock(); + var auth = new Mock(); + var twoFactorInfo = new SignInManager.TwoFactorAuthenticationInfo { UserId = user.Id }; + var helper = SetupSignInManager(manager.Object, context.Object); + if (providerName != null) + { + helper.Options.Tokens.AuthenticatorTokenProvider = providerName; + } + var id = helper.StoreTwoFactorInfo(user.Id, null); + SetupSignIn(auth, user.Id, isPersistent); + auth.Setup(a => a.AuthenticateAsync(helper.Options.Cookies.TwoFactorUserIdCookieAuthenticationScheme)).ReturnsAsync(id).Verifiable(); + context.Setup(c => c.Authentication).Returns(auth.Object).Verifiable(); + if (rememberClient) + { + auth.Setup(a => a.SignInAsync( + helper.Options.Cookies.TwoFactorRememberMeCookieAuthenticationScheme, + It.Is(i => i.FindFirstValue(ClaimTypes.Name) == user.Id + && i.Identities.First().AuthenticationType == helper.Options.Cookies.TwoFactorRememberMeCookieAuthenticationScheme), + It.IsAny())).Returns(Task.FromResult(0)).Verifiable(); + } + + // Act + var result = await helper.TwoFactorAuthenticatorSignInAsync(code, isPersistent, rememberClient); + + // Assert + Assert.True(result.Succeeded); + manager.Verify(); + context.Verify(); + auth.Verify(); + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task CanTwoFactorRecoveryCodeSignIn(bool supportsLockout, bool externalLogin) + { + // Setup + var user = new TestUser { UserName = "Foo" }; + const string bypassCode = "someCode"; + var manager = SetupUserManager(user); + manager.Setup(m => m.SupportsUserLockout).Returns(supportsLockout).Verifiable(); + manager.Setup(m => m.RedeemTwoFactorRecoveryCodeAsync(user, bypassCode)).ReturnsAsync(IdentityResult.Success).Verifiable(); + if (supportsLockout) + { + manager.Setup(m => m.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success).Verifiable(); + } + var context = new Mock(); + var auth = new Mock(); + var twoFactorInfo = new SignInManager.TwoFactorAuthenticationInfo { UserId = user.Id }; + var loginProvider = "loginprovider"; + var helper = SetupSignInManager(manager.Object, context.Object); + var id = helper.StoreTwoFactorInfo(user.Id, externalLogin ? loginProvider : null); + if (externalLogin) + { + auth.Setup(a => a.SignInAsync( + helper.Options.Cookies.ApplicationCookieAuthenticationScheme, + It.Is(i => i.FindFirstValue(ClaimTypes.AuthenticationMethod) == loginProvider + && i.FindFirstValue(ClaimTypes.NameIdentifier) == user.Id), + It.IsAny())).Returns(Task.FromResult(0)).Verifiable(); + auth.Setup(a => a.SignOutAsync(helper.Options.Cookies.ExternalCookieAuthenticationScheme)).Returns(Task.FromResult(0)).Verifiable(); + auth.Setup(a => a.SignOutAsync(helper.Options.Cookies.TwoFactorUserIdCookieAuthenticationScheme)).Returns(Task.FromResult(0)).Verifiable(); + } + else + { + SetupSignIn(auth, user.Id); + } + auth.Setup(a => a.AuthenticateAsync(helper.Options.Cookies.TwoFactorUserIdCookieAuthenticationScheme)).ReturnsAsync(id).Verifiable(); + context.Setup(c => c.Authentication).Returns(auth.Object).Verifiable(); + + // Act + var result = await helper.TwoFactorRecoveryCodeSignInAsync(bypassCode); + + // Assert + Assert.True(result.Succeeded); + manager.Verify(); + context.Verify(); + auth.Verify(); + } + [Theory] [InlineData(true, true)] [InlineData(true, false)] diff --git a/test/Microsoft.AspNetCore.Identity.Test/UserManagerTest.cs b/test/Microsoft.AspNetCore.Identity.Test/UserManagerTest.cs index 61a0e1e0ec..0bcf708f82 100644 --- a/test/Microsoft.AspNetCore.Identity.Test/UserManagerTest.cs +++ b/test/Microsoft.AspNetCore.Identity.Test/UserManagerTest.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -691,6 +693,146 @@ namespace Microsoft.AspNetCore.Identity.Test Assert.ThrowsAsync(() => manager.GenerateUserTokenAsync(new TestUser(), "A", "purpose")); } + [Fact] + public void TOTPTest() + { + //var verify = new TotpAuthenticatorVerification(); + //var secret = "abcdefghij"; + //var secret = Base32.FromBase32(authKey); + +// Assert.Equal(bytes, secret); + + //var code = verify.VerifyCode(secret, -1); + //Assert.Equal(code, 287004); + + + //var bytes = new byte[] { (byte)'H', (byte)'e', (byte)'l', (byte)'l', (byte)'o', (byte)'!', (byte)0xDE, (byte)0xAD, (byte)0xBE, (byte)0xEF }; + //var base32 = Base32.ToBase32(bytes); + // var code = Rfc6238AuthenticationService.GenerateCode(bytes); + // Assert.Equal(Rfc6238AuthenticationService.GenerateCode(bytes), Rfc6238AuthenticationService.CalculateOneTimePassword(new HMACSHA1(bytes))); + //Assert.True(Rfc6238AuthenticationService.ValidateCode(bytes, code)); + } + + public static byte[] ToBytes(string input) + { + if (string.IsNullOrEmpty(input)) + { + throw new ArgumentNullException("input"); + } + + input = input.TrimEnd('='); //remove padding characters + int byteCount = input.Length * 5 / 8; //this must be TRUNCATED + byte[] returnArray = new byte[byteCount]; + + byte curByte = 0, bitsRemaining = 8; + int mask = 0, arrayIndex = 0; + + foreach (char c in input) + { + int cValue = CharToValue(c); + + if (bitsRemaining > 5) + { + mask = cValue << (bitsRemaining - 5); + curByte = (byte)(curByte | mask); + bitsRemaining -= 5; + } + else + { + mask = cValue >> (5 - bitsRemaining); + curByte = (byte)(curByte | mask); + returnArray[arrayIndex++] = curByte; + curByte = (byte)(cValue << (3 + bitsRemaining)); + bitsRemaining += 3; + } + } + + //if we didn't end with a full byte + if (arrayIndex != byteCount) + { + returnArray[arrayIndex] = curByte; + } + + return returnArray; + } + + public static string ToString(byte[] input) + { + if (input == null || input.Length == 0) + { + throw new ArgumentNullException("input"); + } + + int charCount = (int)Math.Ceiling(input.Length / 5d) * 8; + char[] returnArray = new char[charCount]; + + byte nextChar = 0, bitsRemaining = 5; + int arrayIndex = 0; + + foreach (byte b in input) + { + nextChar = (byte)(nextChar | (b >> (8 - bitsRemaining))); + returnArray[arrayIndex++] = ValueToChar(nextChar); + + if (bitsRemaining < 4) + { + nextChar = (byte)((b >> (3 - bitsRemaining)) & 31); + returnArray[arrayIndex++] = ValueToChar(nextChar); + bitsRemaining += 5; + } + + bitsRemaining -= 3; + nextChar = (byte)((b << bitsRemaining) & 31); + } + + //if we didn't end with a full char + if (arrayIndex != charCount) + { + returnArray[arrayIndex++] = ValueToChar(nextChar); + while (arrayIndex != charCount) returnArray[arrayIndex++] = '='; //padding + } + + return new string(returnArray); + } + + private static int CharToValue(char c) + { + var value = (int)c; + + //65-90 == uppercase letters + if (value < 91 && value > 64) + { + return value - 65; + } + //50-55 == numbers 2-7 + if (value < 56 && value > 49) + { + return value - 24; + } + //97-122 == lowercase letters + if (value < 123 && value > 96) + { + return value - 97; + } + + throw new ArgumentException("Character is not a Base32 character.", "c"); + } + + private static char ValueToChar(byte b) + { + if (b < 26) + { + return (char)(b + 65); + } + + if (b < 32) + { + return (char)(b + 24); + } + + throw new ArgumentException("Byte is not a value Base32 value.", "b"); + } + [Fact] public void UserManagerWillUseTokenProviderInstanceOverDefaults() { @@ -741,6 +883,43 @@ namespace Microsoft.AspNetCore.Identity.Test await Assert.ThrowsAsync(async () => await manager.IsInRoleAsync(null, "bogus")); } + [Fact] + public async Task AuthTokenMethodsFailWhenStoreNotImplemented() + { + var error = Resources.StoreNotIUserAuthenticationTokenStore; + var manager = MockHelpers.TestUserManager(new NoopUserStore()); + Assert.False(manager.SupportsUserAuthenticationTokens); + await VerifyException(async () => await manager.GetAuthenticationTokenAsync(null, null, null), error); + await VerifyException(async () => await manager.SetAuthenticationTokenAsync(null, null, null, null), error); + await VerifyException(async () => await manager.RemoveAuthenticationTokenAsync(null, null, null), error); + } + + [Fact] + public async Task AuthenticatorMethodsFailWhenStoreNotImplemented() + { + var error = Resources.StoreNotIUserAuthenticatorKeyStore; + var manager = MockHelpers.TestUserManager(new NoopUserStore()); + Assert.False(manager.SupportsUserAuthenticatorKey); + await VerifyException(async () => await manager.GetAuthenticatorKeyAsync(null), error); + await VerifyException(async () => await manager.ResetAuthenticatorKeyAsync(null), error); + } + + [Fact] + public async Task RecoveryMethodsFailWhenStoreNotImplemented() + { + var error = Resources.StoreNotIUserTwoFactorRecoveryCodeStore; + var manager = MockHelpers.TestUserManager(new NoopUserStore()); + Assert.False(manager.SupportsUserTwoFactorRecoveryCodes); + await VerifyException(async () => await manager.RedeemTwoFactorRecoveryCodeAsync(null, null), error); + await VerifyException(async () => await manager.GenerateNewTwoFactorRecoveryCodesAsync(null, 10), error); + } + + private async Task VerifyException(Func code, string expectedMessage) where TException : Exception + { + var error = await Assert.ThrowsAsync(code); + Assert.Equal(expectedMessage, error.Message); + } + [Fact] public void DisposeAfterDisposeDoesNotThrow() {