Add authenticator support
This commit is contained in:
parent
a9ce48b911
commit
5aed9742a4
|
|
@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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)
|
||||
|
|
|
|||
|
|
@ -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<IActionResult> 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<IActionResult> 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]
|
||||
|
|
|
|||
|
|
@ -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<string>", 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<string>", 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<string>", 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<string>", 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<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId");
|
||||
|
||||
b.Property<string>("LoginProvider");
|
||||
|
||||
b.Property<string>("Name");
|
||||
|
||||
b.Property<string>("Value");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WebApplication13.Models.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<string>("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<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole")
|
||||
.WithMany()
|
||||
.WithMany("Claims")
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserClaim<string>", 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<string>", 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<string>", 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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string>(nullable: false),
|
||||
ConcurrencyStamp = table.Column<string>(nullable: true),
|
||||
Name = table.Column<string>(nullable: true),
|
||||
NormalizedName = table.Column<string>(nullable: true)
|
||||
Name = table.Column<string>(maxLength: 256, nullable: true),
|
||||
NormalizedName = table.Column<string>(maxLength: 256, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserTokens",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(nullable: false),
|
||||
LoginProvider = table.Column<string>(nullable: false),
|
||||
Name = table.Column<string>(nullable: false),
|
||||
Value = table.Column<string>(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<string>(nullable: false),
|
||||
AccessFailedCount = table.Column<int>(nullable: false),
|
||||
ConcurrencyStamp = table.Column<string>(nullable: true),
|
||||
Email = table.Column<string>(nullable: true),
|
||||
Email = table.Column<string>(maxLength: 256, nullable: true),
|
||||
EmailConfirmed = table.Column<bool>(nullable: false),
|
||||
LockoutEnabled = table.Column<bool>(nullable: false),
|
||||
LockoutEnd = table.Column<DateTimeOffset>(nullable: true),
|
||||
NormalizedEmail = table.Column<string>(nullable: true),
|
||||
NormalizedUserName = table.Column<string>(nullable: true),
|
||||
NormalizedEmail = table.Column<string>(maxLength: 256, nullable: true),
|
||||
NormalizedUserName = table.Column<string>(maxLength: 256, nullable: true),
|
||||
PasswordHash = table.Column<string>(nullable: true),
|
||||
PhoneNumber = table.Column<string>(nullable: true),
|
||||
PhoneNumberConfirmed = table.Column<bool>(nullable: false),
|
||||
SecurityStamp = table.Column<string>(nullable: true),
|
||||
TwoFactorEnabled = table.Column<bool>(nullable: false),
|
||||
UserName = table.Column<string>(nullable: true)
|
||||
UserName = table.Column<string>(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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>", 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<string>", 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<string>", 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<string>", 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<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId");
|
||||
|
||||
b.Property<string>("LoginProvider");
|
||||
|
||||
b.Property<string>("Name");
|
||||
|
||||
b.Property<string>("Value");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("IdentitySample.Models.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<string>("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<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityRole")
|
||||
.WithMany()
|
||||
.WithMany("Claims")
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserClaim<string>", 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<string>", 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<string>", 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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace IdentitySample.Models.ManageViewModels
|
||||
{
|
||||
public class DisplayRecoveryCodesViewModel
|
||||
{
|
||||
[Required]
|
||||
public IEnumerable<string> Codes { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -17,5 +17,7 @@ namespace IdentitySample.Models.ManageViewModels
|
|||
public bool TwoFactor { get; set; }
|
||||
|
||||
public bool BrowserRemembered { get; set; }
|
||||
|
||||
public string AuthenticatorKey { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Startup>()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNET_ENVIRONMENT": "Development"
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
|
|
|
|||
|
|
@ -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<ApplicationDbContext>(options =>
|
||||
options.UseSqlServer(Configuration["Data:DefaultConnection:ConnectionString"]));
|
||||
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
services.AddIdentity<ApplicationUser, IdentityRole>(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<ApplicationUser, IdentityRole>()
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.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<IServiceScopeFactory>()
|
||||
.CreateScope())
|
||||
{
|
||||
serviceScope.ServiceProvider.GetService<ApplicationDbContext>()
|
||||
.Database.Migrate();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.UseIdentity();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
@model UseRecoveryCodeViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Use recovery code";
|
||||
}
|
||||
|
||||
<h2>@ViewData["Title"].</h2>
|
||||
|
||||
<form asp-controller="Account" asp-action="UseRecoveryCode" asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" class="form-horizontal" role="form">
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<h4>@ViewData["Status"]</h4>
|
||||
<hr />
|
||||
<div class="form-group">
|
||||
<label asp-for="Code" class="col-md-2 control-label"></label>
|
||||
<div class="col-md-10">
|
||||
<input asp-for="Code" class="form-control" />
|
||||
<span asp-validation-for="Code" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-md-offset-2 col-md-10">
|
||||
<button type="submit" class="btn btn-default">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@section Scripts {
|
||||
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
@model VerifyAuthenticatorCodeViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Verify";
|
||||
}
|
||||
|
||||
<h2>@ViewData["Title"].</h2>
|
||||
|
||||
<form asp-controller="Account" asp-action="VerifyAuthenticatorCode" asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" class="form-horizontal" role="form">
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<input asp-for="RememberMe" type="hidden" />
|
||||
<h4>@ViewData["Status"]</h4>
|
||||
<hr />
|
||||
<div class="form-group">
|
||||
<label asp-for="Code" class="col-md-2 control-label"></label>
|
||||
<div class="col-md-10">
|
||||
<input asp-for="Code" class="form-control" />
|
||||
<span asp-validation-for="Code" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-md-offset-2 col-md-10">
|
||||
<div class="checkbox">
|
||||
<input asp-for="RememberBrowser" />
|
||||
<label asp-for="RememberBrowser"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-md-offset-2 col-md-10">
|
||||
<button type="submit" class="btn btn-default">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<a asp-action="UseRecoveryCode">Lost your authenticator?</a>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@section Scripts {
|
||||
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
@model DisplayRecoveryCodesViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Your recovery codes:";
|
||||
}
|
||||
|
||||
<h2>@ViewData["Title"].</h2>
|
||||
<p class="text-success">@ViewData["StatusMessage"]</p>
|
||||
|
||||
<div>
|
||||
<h4>Here are your new recovery codes</h4>
|
||||
<hr />
|
||||
<dl class="dl-horizontal">
|
||||
<dt>Codes:</dt>
|
||||
@foreach (var code in Model.Codes)
|
||||
{
|
||||
<dd>
|
||||
<text>@code</text>
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
See <a href="http://go.microsoft.com/fwlink/?LinkID=532713">this article</a>
|
||||
for details on setting up this ASP.NET application to support two-factor authentication using SMS.
|
||||
</p>
|
||||
@*@(Model.PhoneNumber ?? "None")
|
||||
@(Model.PhoneNumber ?? "None")
|
||||
@if (Model.PhoneNumber != null)
|
||||
{
|
||||
<br />
|
||||
|
|
@ -44,16 +44,16 @@
|
|||
else
|
||||
{
|
||||
<text>[ <a asp-controller="Manage" asp-action="AddPhoneNumber">Add</a> ]</text>
|
||||
}*@
|
||||
}
|
||||
</dd>
|
||||
|
||||
<dt>Two-Factor Authentication:</dt>
|
||||
<dd>
|
||||
<p>
|
||||
<!--<p>
|
||||
There are no two-factor authentication providers configured. See <a href="http://go.microsoft.com/fwlink/?LinkID=532713">this article</a>
|
||||
for setting up this application to support two-factor authentication.
|
||||
</p>
|
||||
@*@if (Model.TwoFactor)
|
||||
</p>-->
|
||||
@if (Model.TwoFactor)
|
||||
{
|
||||
<form asp-controller="Manage" asp-action="DisableTwoFactorAuthentication" method="post" class="form-horizontal" role="form">
|
||||
Enabled [<button type="submit" class="btn-link">Disable</button>]
|
||||
|
|
@ -64,7 +64,23 @@
|
|||
<form asp-controller="Manage" asp-action="EnableTwoFactorAuthentication" method="post" class="form-horizontal" role="form">
|
||||
[<button type="submit" class="btn-link">Enable</button>] Disabled
|
||||
</form>
|
||||
}*@
|
||||
}
|
||||
</dd>
|
||||
<dt>Authentication App:</dt>
|
||||
<dd>
|
||||
@if (Model.AuthenticatorKey == null)
|
||||
{
|
||||
<form asp-controller="Manage" asp-action="ResetAuthenticatorKey" method="post" class="form-horizontal" role="form">
|
||||
Generate [<button type="submit" class="btn-link">Generate</button>]
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Your key is: @Model.AuthenticatorKey</text>
|
||||
<form asp-controller="Manage" asp-action="GenerateRecoveryCode" method="post" class="form-horizontal" role="form">
|
||||
Generate [<button type="submit" class="btn-link">Generate new recovery codes</button>]
|
||||
</form>
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
@RenderBody()
|
||||
<hr />
|
||||
<footer>
|
||||
<p>© $year$ - $safeprojectname$</p>
|
||||
<p>© 2016 - IdentitySample</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -201,7 +201,9 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore
|
|||
IUserPhoneNumberStore<TUser>,
|
||||
IQueryableUserStore<TUser>,
|
||||
IUserTwoFactorStore<TUser>,
|
||||
IUserAuthenticationTokenStore<TUser>
|
||||
IUserAuthenticationTokenStore<TUser>,
|
||||
IUserAuthenticatorKeyStore<TUser>,
|
||||
IUserTwoFactorRecoveryCodeStore<TUser>
|
||||
where TUser : IdentityUser<TKey, TUserClaim, TUserRole, TUserLogin>
|
||||
where TRole : IdentityRole<TKey, TUserRole, TRoleClaim>
|
||||
where TContext : DbContext
|
||||
|
|
@ -1411,7 +1413,7 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore
|
|||
/// <param name="name">The name of the token.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
|
||||
/// <returns>The <see cref="Task"/> that represents the asynchronous operation.</returns>
|
||||
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
|
|||
/// <param name="name">The name of the token.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
|
||||
/// <returns>The <see cref="Task"/> that represents the asynchronous operation.</returns>
|
||||
public async virtual Task<string> GetTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken)
|
||||
public virtual async Task<string> 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";
|
||||
|
||||
/// <summary>
|
||||
/// Sets the authenticator key for the specified <paramref name="user"/>.
|
||||
/// </summary>
|
||||
/// <param name="user">The user whose authenticator key should be set.</param>
|
||||
/// <param name="key">The authenticator key to set.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
|
||||
/// <returns>The <see cref="Task"/> that represents the asynchronous operation.</returns>
|
||||
public virtual Task SetAuthenticatorKeyAsync(TUser user, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
return SetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, key, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the authenticator key for the specified <paramref name="user" />.
|
||||
/// </summary>
|
||||
/// <param name="user">The user whose security stamp should be set.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
|
||||
/// <returns>The <see cref="Task"/> that represents the asynchronous operation, containing the security stamp for the specified <paramref name="user"/>.</returns>
|
||||
public virtual Task<string> GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken)
|
||||
{
|
||||
return GetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the recovery codes for the user while invalidating any previous recovery codes.
|
||||
/// </summary>
|
||||
/// <param name="user">The user to store new recovery codes for.</param>
|
||||
/// <param name="recoveryCodes">The new recovery codes for the user.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
|
||||
/// <returns>The new recovery codes for the user.</returns>
|
||||
public virtual Task ReplaceCodesAsync(TUser user, IEnumerable<string> recoveryCodes, CancellationToken cancellationToken)
|
||||
{
|
||||
var mergedCodes = string.Join(";", recoveryCodes);
|
||||
return SetTokenAsync(user, InternalLoginProvider, RecoveryCodeTokenName, mergedCodes, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a recovery code is valid for a user. Note: recovery codes are only valid
|
||||
/// once, and will be invalid after use.
|
||||
/// </summary>
|
||||
/// <param name="user">The user who owns the recovery code.</param>
|
||||
/// <param name="code">The recovery code to use.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
|
||||
/// <returns>True if the recovery code was found for the user.</returns>
|
||||
public virtual async Task<bool> 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<string>(splitCodes.Where(s => s != code));
|
||||
await ReplaceCodesAsync(user, updatedCodes, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test.
|
||||
/// </summary>
|
||||
/// <returns>Task</returns>
|
||||
[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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test.
|
||||
/// </summary>
|
||||
/// <returns>Task</returns>
|
||||
[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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test.
|
||||
/// </summary>
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Used for authenticator code verification.
|
||||
/// </summary>
|
||||
public class AuthenticatorTokenProvider<TUser> : IUserTwoFactorTokenProvider<TUser> where TUser : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a two factor authentication token can be generated for the specified <paramref name="user"/>.
|
||||
/// </summary>
|
||||
/// <param name="manager">The <see cref="UserManager{TUser}"/> to retrieve the <paramref name="user"/> from.</param>
|
||||
/// <param name="user">The <typeparamref name="TUser"/> to check for the possibility of generating a two factor authentication token.</param>
|
||||
/// <returns>True if the user has an authenticator key set, otherwise false.</returns>
|
||||
public async virtual Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<TUser> manager, TUser user)
|
||||
{
|
||||
var key = await manager.GetAuthenticatorKeyAsync(user);
|
||||
return !string.IsNullOrWhiteSpace(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an empty string since no authenticator codes are sent.
|
||||
/// </summary>
|
||||
/// <param name="purpose">Ignored.</param>
|
||||
/// <param name="manager">The <see cref="UserManager{TUser}"/> to retrieve the <paramref name="user"/> from.</param>
|
||||
/// <param name="user">The <typeparamref name="TUser"/>.</param>
|
||||
/// <returns>string.Empty.</returns>
|
||||
public virtual Task<string> GenerateAsync(string purpose, UserManager<TUser> manager, TUser user)
|
||||
{
|
||||
return Task.FromResult(string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="purpose"></param>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="manager"></param>
|
||||
/// <param name="user"></param>
|
||||
/// <returns></returns>
|
||||
public virtual async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides an abstraction for a store which stores info about user's authenticator.
|
||||
/// </summary>
|
||||
/// <typeparam name="TUser">The type encapsulating a user.</typeparam>
|
||||
public interface IUserAuthenticatorKeyStore<TUser> : IUserStore<TUser> where TUser : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the authenticator key for the specified <paramref name="user"/>.
|
||||
/// </summary>
|
||||
/// <param name="user">The user whose authenticator key should be set.</param>
|
||||
/// <param name="key">The authenticator key to set.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
|
||||
/// <returns>The <see cref="Task"/> that represents the asynchronous operation.</returns>
|
||||
Task SetAuthenticatorKeyAsync(TUser user, string key, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Get the authenticator key for the specified <paramref name="user" />.
|
||||
/// </summary>
|
||||
/// <param name="user">The user whose security stamp should be set.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
|
||||
/// <returns>The <see cref="Task"/> that represents the asynchronous operation, containing the security stamp for the specified <paramref name="user"/>.</returns>
|
||||
Task<string> GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides an abstraction for a store which stores a user's recovery codes.
|
||||
/// </summary>
|
||||
/// <typeparam name="TUser">The type encapsulating a user.</typeparam>
|
||||
public interface IUserTwoFactorRecoveryCodeStore<TUser> : IUserStore<TUser> where TUser : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Updates the recovery codes for the user while invalidating any previous recovery codes.
|
||||
/// </summary>
|
||||
/// <param name="user">The user to store new recovery codes for.</param>
|
||||
/// <param name="recoveryCodes">The new recovery codes for the user.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
|
||||
/// <returns>The new recovery codes for the user.</returns>
|
||||
Task ReplaceCodesAsync(TUser user, IEnumerable<string> recoveryCodes, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a recovery code is valid for a user. Note: recovery codes are only valid
|
||||
/// once, and will be invalid after use.
|
||||
/// </summary>
|
||||
/// <param name="user">The user who owns the recovery code.</param>
|
||||
/// <param name="code">The recovery code to use.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
|
||||
/// <returns>True if the recovery code was found for the user.</returns>
|
||||
Task<bool> RedeemCodeAsync(TUser user, string code, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,19 @@ namespace Microsoft.AspNetCore.Identity
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an <see cref="IdentityError"/> indicating a recovery code was not redeemed.
|
||||
/// </summary>
|
||||
/// <returns>An <see cref="IdentityError"/> indicating a recovery code was not redeemed.</returns>
|
||||
public virtual IdentityError RecoveryCodeRedemptionFailed()
|
||||
{
|
||||
return new IdentityError
|
||||
{
|
||||
Code = nameof(RecoveryCodeRedemptionFailed),
|
||||
Description = Resources.RecoveryCodeRedemptionFailed
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an <see cref="IdentityError"/> indicating an external login is already associated with an account.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -426,6 +426,22 @@ namespace Microsoft.AspNetCore.Identity
|
|||
return GetString("StoreNotIRoleClaimStore");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store does not implement IUserAuthenticationTokenStore<User>.
|
||||
/// </summary>
|
||||
internal static string StoreNotIUserAuthenticationTokenStore
|
||||
{
|
||||
get { return GetString("StoreNotIUserAuthenticationTokenStore"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store does not implement IUserAuthenticationTokenStore<User>.
|
||||
/// </summary>
|
||||
internal static string FormatStoreNotIUserAuthenticationTokenStore()
|
||||
{
|
||||
return GetString("StoreNotIUserAuthenticationTokenStore");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store does not implement IUserClaimStore<TUser>.
|
||||
/// </summary>
|
||||
|
|
@ -570,6 +586,22 @@ namespace Microsoft.AspNetCore.Identity
|
|||
return GetString("StoreNotIUserSecurityStampStore");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store does not implement IUserAuthenticatorKeyStore<User>.
|
||||
/// </summary>
|
||||
internal static string StoreNotIUserAuthenticatorKeyStore
|
||||
{
|
||||
get { return GetString("StoreNotIUserAuthenticatorKeyStore"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store does not implement IUserAuthenticatorKeyStore<User>.
|
||||
/// </summary>
|
||||
internal static string FormatStoreNotIUserAuthenticatorKeyStore()
|
||||
{
|
||||
return GetString("StoreNotIUserAuthenticatorKeyStore");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store does not implement IUserTwoFactorStore<TUser>.
|
||||
/// </summary>
|
||||
|
|
@ -586,6 +618,22 @@ namespace Microsoft.AspNetCore.Identity
|
|||
return GetString("StoreNotIUserTwoFactorStore");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recovery code redemption failed.
|
||||
/// </summary>
|
||||
internal static string RecoveryCodeRedemptionFailed
|
||||
{
|
||||
get { return GetString("RecoveryCodeRedemptionFailed"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recovery code redemption failed.
|
||||
/// </summary>
|
||||
internal static string FormatRecoveryCodeRedemptionFailed()
|
||||
{
|
||||
return GetString("RecoveryCodeRedemptionFailed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User already has a password set.
|
||||
/// </summary>
|
||||
|
|
@ -682,6 +730,22 @@ namespace Microsoft.AspNetCore.Identity
|
|||
return string.Format(CultureInfo.CurrentCulture, GetString("UserNotInRole"), p0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store does not implement IUserTwoFactorRecoveryCodeStore<User>.
|
||||
/// </summary>
|
||||
internal static string StoreNotIUserTwoFactorRecoveryCodeStore
|
||||
{
|
||||
get { return GetString("StoreNotIUserTwoFactorRecoveryCodeStore"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store does not implement IUserTwoFactorRecoveryCodeStore<User>.
|
||||
/// </summary>
|
||||
internal static string FormatStoreNotIUserTwoFactorRecoveryCodeStore()
|
||||
{
|
||||
return GetString("StoreNotIUserTwoFactorRecoveryCodeStore");
|
||||
}
|
||||
|
||||
private static string GetString(string name, params string[] formatterNames)
|
||||
{
|
||||
var value = _resourceManager.GetString(name);
|
||||
|
|
|
|||
|
|
@ -221,6 +221,10 @@
|
|||
<value>Store does not implement IRoleClaimStore<TRole>.</value>
|
||||
<comment>Error when the store does not implement this interface</comment>
|
||||
</data>
|
||||
<data name="StoreNotIUserAuthenticationTokenStore" xml:space="preserve">
|
||||
<value>Store does not implement IUserAuthenticationTokenStore<User>.</value>
|
||||
<comment>Error when the store does not implement this interface</comment>
|
||||
</data>
|
||||
<data name="StoreNotIUserClaimStore" xml:space="preserve">
|
||||
<value>Store does not implement IUserClaimStore<TUser>.</value>
|
||||
<comment>Error when the store does not implement this interface</comment>
|
||||
|
|
@ -257,10 +261,18 @@
|
|||
<value>Store does not implement IUserSecurityStampStore<TUser>.</value>
|
||||
<comment>Error when the store does not implement this interface</comment>
|
||||
</data>
|
||||
<data name="StoreNotIUserAuthenticatorKeyStore" xml:space="preserve">
|
||||
<value>Store does not implement IUserAuthenticatorKeyStore<User>.</value>
|
||||
<comment>Error when the store does not implement this interface</comment>
|
||||
</data>
|
||||
<data name="StoreNotIUserTwoFactorStore" xml:space="preserve">
|
||||
<value>Store does not implement IUserTwoFactorStore<TUser>.</value>
|
||||
<comment>Error when the store does not implement this interface</comment>
|
||||
</data>
|
||||
<data name="RecoveryCodeRedemptionFailed" xml:space="preserve">
|
||||
<value>Recovery code redemption failed.</value>
|
||||
<comment>Error when a recovery code is not redeemed.</comment>
|
||||
</data>
|
||||
<data name="UserAlreadyHasPassword" xml:space="preserve">
|
||||
<value>User already has a password set.</value>
|
||||
<comment>Error when AddPasswordAsync called when a user already has a password</comment>
|
||||
|
|
@ -285,4 +297,8 @@
|
|||
<value>User is not in role '{0}'.</value>
|
||||
<comment>Error when a user is not in the role</comment>
|
||||
</data>
|
||||
<data name="StoreNotIUserTwoFactorRecoveryCodeStore" xml:space="preserve">
|
||||
<value>Store does not implement IUserTwoFactorRecoveryCodeStore<User>.</value>
|
||||
<comment>Error when the store does not implement this interface</comment>
|
||||
</data>
|
||||
</root>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signs in the user without two factor authentication using a two factor recovery code.
|
||||
/// </summary>
|
||||
/// <param name="recoveryCode">The two factor recovery code.</param>
|
||||
/// <returns></returns>
|
||||
public virtual async Task<SignInResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the sign in code from an authenticator app and creates and signs in the user, as an asynchronous operation.
|
||||
/// </summary>
|
||||
/// <param name="code">The two factor authentication code to validate.</param>
|
||||
/// <param name="isPersistent">Flag indicating whether the sign-in cookie should persist after the browser is closed.</param>
|
||||
/// <param name="rememberClient">Flag indicating whether the current browser should be remember, suppressing all further
|
||||
/// two factor authentication prompts.</param>
|
||||
/// <returns>The task object representing the asynchronous operation containing the <see name="SignInResult"/>
|
||||
/// for the sign-in attempt.</returns>
|
||||
public virtual async Task<SignInResult> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the two faction sign in code and creates and signs in the user, as an asynchronous operation.
|
||||
/// </summary>
|
||||
|
|
@ -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<SignInResult> SignInOrTwoFactorAsync(TUser user, bool isPersistent, string loginProvider = null, bool bypassTwoFactor = false)
|
||||
{
|
||||
if (!bypassTwoFactor &&
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ namespace Microsoft.AspNetCore.Identity
|
|||
/// </summary>
|
||||
public static readonly string DefaultPhoneProvider = "Phone";
|
||||
|
||||
/// <summary>
|
||||
/// Default token provider name used by the <see cref="AuthenticatorTokenProvider{TUser}"/>.
|
||||
/// </summary>
|
||||
public static readonly string DefaultAuthenticatorProvider = "Authenticator";
|
||||
|
||||
/// <summary>
|
||||
/// Will be used to construct UserTokenProviders with the key used as the providerName.
|
||||
/// </summary>
|
||||
|
|
@ -54,5 +59,13 @@ namespace Microsoft.AspNetCore.Identity
|
|||
/// The <see cref="ChangeEmailTokenProvider"/> used to generate tokens used in email change confirmation emails.
|
||||
/// </value>
|
||||
public string ChangeEmailTokenProvider { get; set; } = DefaultProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="AuthenticatorTokenProvider"/> used to validate two factor sign ins with an authenticator.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The <see cref="AuthenticatorTokenProvider"/> used to validate two factor sign ins with an authenticator.
|
||||
/// </value>
|
||||
public string AuthenticatorTokenProvider { get; set; } = DefaultAuthenticatorProvider;
|
||||
}
|
||||
}
|
||||
|
|
@ -158,7 +158,7 @@ namespace Microsoft.AspNetCore.Identity
|
|||
/// Gets a flag indicating whether the backing user store supports authentication tokens.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// true if the backing user store supports authentication tokens, otherwise false.
|
||||
/// true if the backing user store supports authentication tokens, otherwise false.
|
||||
/// </value>
|
||||
public virtual bool SupportsUserAuthenticationTokens
|
||||
{
|
||||
|
|
@ -169,6 +169,36 @@ namespace Microsoft.AspNetCore.Identity
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a flag indicating whether the backing user store supports a user authenticator.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// true if the backing user store supports a user authenticatior, otherwise false.
|
||||
/// </value>
|
||||
public virtual bool SupportsUserAuthenticatorKey
|
||||
{
|
||||
get
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return Store is IUserAuthenticatorKeyStore<TUser>;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a flag indicating whether the backing user store supports recovery codes.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// true if the backing user store supports a user authenticatior, otherwise false.
|
||||
/// </value>
|
||||
public virtual bool SupportsUserTwoFactorRecoveryCodes
|
||||
{
|
||||
get
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return Store is IUserTwoFactorRecoveryCodeStore<TUser>;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a flag indicating whether the backing user store supports two factor authentication.
|
||||
/// </summary>
|
||||
|
|
@ -2013,11 +2043,11 @@ namespace Microsoft.AspNetCore.Identity
|
|||
/// <param name="user"></param>
|
||||
/// <param name="loginProvider">The authentication scheme for the provider the token is associated with.</param>
|
||||
/// <param name="tokenName">The name of the token.</param>
|
||||
/// <returns></returns>
|
||||
/// <returns>The authentication token for a user</returns>
|
||||
public virtual Task<string> 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
|
|||
/// <param name="loginProvider">The authentication scheme for the provider the token is associated with.</param>
|
||||
/// <param name="tokenName">The name of the token.</param>
|
||||
/// <param name="tokenValue">The value of the token.</param>
|
||||
/// <returns></returns>
|
||||
/// <returns>Whether the user was successfully updated.</returns>
|
||||
public virtual async Task<IdentityResult> 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<IdentityResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the authenticator key for the user.
|
||||
/// </summary>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>The authenticator key</returns>
|
||||
public virtual Task<string> GetAuthenticatorKeyAsync(TUser user)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
var store = GetAuthenticatorKeyStore();
|
||||
if (user == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
}
|
||||
return store.GetAuthenticatorKeyAsync(user, CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the authenticator key for the user.
|
||||
/// </summary>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>Whether the user was successfully updated.</returns>
|
||||
public virtual async Task<IdentityResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new base32 encoded 160-bit security secret (size of SHA1 hash).
|
||||
/// </summary>
|
||||
/// <returns>The new security secret.</returns>
|
||||
public virtual string GenerateNewAuthenticatorKey()
|
||||
{
|
||||
return Base32.ToBase32(Rfc6238AuthenticationService.GenerateRandomKey());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates recovery codes for the user, this invalidates any previous recovery codes for the user.
|
||||
/// </summary>
|
||||
/// <param name="user">The user to generate recovery codes for.</param>
|
||||
/// <param name="number">The number of codes to generate.</param>
|
||||
/// <returns>The new recovery codes for the user.</returns>
|
||||
public virtual async Task<IEnumerable<string>> GenerateNewTwoFactorRecoveryCodesAsync(TUser user, int number)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
var store = GetRecoveryCodeStore();
|
||||
if (user == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
}
|
||||
|
||||
var newCodes = new List<string>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a new recovery code.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
protected virtual string CreateTwoFactorRecoveryCode()
|
||||
{
|
||||
return Guid.NewGuid().ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a recovery code is valid for a user. Note: recovery codes are only valid
|
||||
/// once, and will be invalid after use.
|
||||
/// </summary>
|
||||
/// <param name="user">The user who owns the recovery code.</param>
|
||||
/// <param name="code">The recovery code to use.</param>
|
||||
/// <returns>True if the recovery code was found for the user.</returns>
|
||||
public virtual async Task<IdentityResult> 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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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<TUser> GetTokenStore()
|
||||
private IUserAuthenticatorKeyStore<TUser> GetAuthenticatorKeyStore()
|
||||
{
|
||||
var cast = Store as IUserAuthenticatorKeyStore<TUser>;
|
||||
if (cast == null)
|
||||
{
|
||||
throw new NotSupportedException(Resources.StoreNotIUserAuthenticatorKeyStore);
|
||||
}
|
||||
return cast;
|
||||
}
|
||||
|
||||
private IUserTwoFactorRecoveryCodeStore<TUser> GetRecoveryCodeStore()
|
||||
{
|
||||
var cast = Store as IUserTwoFactorRecoveryCodeStore<TUser>;
|
||||
if (cast == null)
|
||||
{
|
||||
throw new NotSupportedException(Resources.StoreNotIUserTwoFactorRecoveryCodeStore);
|
||||
}
|
||||
return cast;
|
||||
}
|
||||
|
||||
private IUserAuthenticationTokenStore<TUser> GetAuthenticationTokenStore()
|
||||
{
|
||||
var cast = Store as IUserAuthenticationTokenStore<TUser>;
|
||||
if (cast == null)
|
||||
{
|
||||
throw new NotSupportedException("Resources.StoreNotIUserAuthenticationTokenStore");
|
||||
throw new NotSupportedException(Resources.StoreNotIUserAuthenticationTokenStore);
|
||||
}
|
||||
return cast;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ namespace Microsoft.AspNetCore.Identity.InMemory
|
|||
IUserTwoFactorStore<TUser>,
|
||||
IQueryableRoleStore<TRole>,
|
||||
IRoleClaimStore<TRole>,
|
||||
IUserAuthenticationTokenStore<TUser>
|
||||
IUserAuthenticationTokenStore<TUser>,
|
||||
IUserAuthenticatorKeyStore<TUser>,
|
||||
IUserTwoFactorRecoveryCodeStore<TUser>
|
||||
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<string> GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken)
|
||||
{
|
||||
return GetTokenAsync(user, AuthenticatorStoreLoginProvider, AuthenticatorKeyTokenName, cancellationToken);
|
||||
}
|
||||
|
||||
public Task ReplaceCodesAsync(TUser user, IEnumerable<string> recoveryCodes, CancellationToken cancellationToken)
|
||||
{
|
||||
var mergedCodes = string.Join(";", recoveryCodes);
|
||||
return SetTokenAsync(user, AuthenticatorStoreLoginProvider, RecoveryCodeTokenName, mergedCodes, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> 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<string>(splitCodes.Where(s => s != code));
|
||||
await ReplaceCodesAsync(user, updatedCodes, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public IQueryable<TRole> Roles
|
||||
{
|
||||
get { return _roles.Values.AsQueryable(); }
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ namespace Microsoft.AspNetCore.Identity.Test
|
|||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var tokenProviders = provider.GetRequiredService<IOptions<IdentityOptions>>().Value.Tokens.ProviderMap.Values;
|
||||
Assert.Equal(3, tokenProviders.Count());
|
||||
Assert.Equal(4, tokenProviders.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -360,6 +360,111 @@ namespace Microsoft.AspNetCore.Identity.Test
|
|||
auth.Verify();
|
||||
}
|
||||
|
||||
private class GoodTokenProvider : AuthenticatorTokenProvider<TestUser>
|
||||
{
|
||||
public override Task<bool> ValidateAsync(string purpose, string token, UserManager<TestUser> 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<HttpContext>();
|
||||
var auth = new Mock<AuthenticationManager>();
|
||||
var twoFactorInfo = new SignInManager<TestUser>.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<ClaimsPrincipal>(i => i.FindFirstValue(ClaimTypes.Name) == user.Id
|
||||
&& i.Identities.First().AuthenticationType == helper.Options.Cookies.TwoFactorRememberMeCookieAuthenticationScheme),
|
||||
It.IsAny<AuthenticationProperties>())).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<HttpContext>();
|
||||
var auth = new Mock<AuthenticationManager>();
|
||||
var twoFactorInfo = new SignInManager<TestUser>.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<ClaimsPrincipal>(i => i.FindFirstValue(ClaimTypes.AuthenticationMethod) == loginProvider
|
||||
&& i.FindFirstValue(ClaimTypes.NameIdentifier) == user.Id),
|
||||
It.IsAny<AuthenticationProperties>())).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)]
|
||||
|
|
|
|||
|
|
@ -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<NotImplementedException>(() => 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<NotSupportedException>(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<NotSupportedException>(async () => await manager.GetAuthenticationTokenAsync(null, null, null), error);
|
||||
await VerifyException<NotSupportedException>(async () => await manager.SetAuthenticationTokenAsync(null, null, null, null), error);
|
||||
await VerifyException<NotSupportedException>(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<NotSupportedException>(async () => await manager.GetAuthenticatorKeyAsync(null), error);
|
||||
await VerifyException<NotSupportedException>(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<NotSupportedException>(async () => await manager.RedeemTwoFactorRecoveryCodeAsync(null, null), error);
|
||||
await VerifyException<NotSupportedException>(async () => await manager.GenerateNewTwoFactorRecoveryCodesAsync(null, 10), error);
|
||||
}
|
||||
|
||||
private async Task VerifyException<TException>(Func<Task> code, string expectedMessage) where TException : Exception
|
||||
{
|
||||
var error = await Assert.ThrowsAsync<TException>(code);
|
||||
Assert.Equal(expectedMessage, error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeAfterDisposeDoesNotThrow()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue