From 5aed9742a4f1a35558f5ca644e981398df7017c1 Mon Sep 17 00:00:00 2001
From: Hao Kung
Date: Tue, 27 Dec 2016 12:59:44 -0800
Subject: [PATCH] Add authenticator support
---
.../Controllers/AccountController.cs | 92 +++++++++
.../Controllers/ManageController.cs | 34 +++-
...000000000_CreateIdentitySchema.Designer.cs | 58 ++++--
.../00000000000000_CreateIdentitySchema.cs | 34 +++-
.../ApplicationDbContextModelSnapshot.cs | 58 ++++--
.../UseRecoveryCodeViewModel.cs | 12 ++
.../VerifyAuthenticatorCodeViewModel.cs | 18 ++
.../AccountViewModels/VerifyCodeViewModel.cs | 6 +-
.../DisplayRecoveryCodesViewModel.cs | 12 ++
.../Models/ManageViewModels/IndexViewModel.cs | 2 +
samples/IdentitySample.Mvc/Program.cs | 4 -
.../Properties/launchSettings.json | 2 +-
samples/IdentitySample.Mvc/Startup.cs | 32 +---
.../Views/Account/UseRecoveryCode.cshtml | 28 +++
.../Account/VerifyAuthenticatorCode.cshtml | 40 ++++
.../Views/Manage/DisplayRecoveryCodes.cshtml | 21 ++
.../Views/Manage/Index.cshtml | 28 ++-
.../Views/Shared/_Layout.cshtml | 2 +-
samples/IdentitySample.Mvc/appsettings.json | 6 +-
.../UserStore.cs | 81 +++++++-
.../IdentitySpecificationTestBase.cs | 70 +++++++
.../AuthenticatorTokenprovider.cs | 71 +++++++
src/Microsoft.AspNetCore.Identity/Base32.cs | 119 ++++++++++++
.../IUserAuthenticatorInfoStore.cs | 33 ++++
.../IUserTwoFactorRecoveryCodeStore.cs | 35 ++++
.../IdentityBuilder.cs | 4 +-
.../IdentityErrorDescriber.cs | 13 ++
.../Properties/Resources.Designer.cs | 64 +++++++
.../Resources.resx | 16 ++
.../Rfc6238AuthenticationService.cs | 16 +-
.../SignInManager.cs | 105 ++++++++--
.../TokenOptions.cs | 13 ++
.../UserManager.cs | 170 ++++++++++++++++-
.../InMemoryStore.cs | 37 +++-
.../IdentityBuilderTest.cs | 2 +-
.../SignInManagerTest.cs | 105 ++++++++++
.../UserManagerTest.cs | 179 ++++++++++++++++++
37 files changed, 1490 insertions(+), 132 deletions(-)
create mode 100644 samples/IdentitySample.Mvc/Models/AccountViewModels/UseRecoveryCodeViewModel.cs
create mode 100644 samples/IdentitySample.Mvc/Models/AccountViewModels/VerifyAuthenticatorCodeViewModel.cs
create mode 100644 samples/IdentitySample.Mvc/Models/ManageViewModels/DisplayRecoveryCodesViewModel.cs
create mode 100644 samples/IdentitySample.Mvc/Views/Account/UseRecoveryCode.cshtml
create mode 100644 samples/IdentitySample.Mvc/Views/Account/VerifyAuthenticatorCode.cshtml
create mode 100644 samples/IdentitySample.Mvc/Views/Manage/DisplayRecoveryCodes.cshtml
create mode 100644 src/Microsoft.AspNetCore.Identity/AuthenticatorTokenprovider.cs
create mode 100644 src/Microsoft.AspNetCore.Identity/Base32.cs
create mode 100644 src/Microsoft.AspNetCore.Identity/IUserAuthenticatorInfoStore.cs
create mode 100644 src/Microsoft.AspNetCore.Identity/IUserTwoFactorRecoveryCodeStore.cs
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"].
+
+
+
+@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"].
+
+
+
+@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)
{
- }*@
+ }
+
+ Authentication App:
+
+ @if (Model.AuthenticatorKey == null)
+ {
+
+ }
+ else
+ {
+ Your key is: @Model.AuthenticatorKey
+
+ }
\ 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()
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()
{